Fix bin/publish: copy docs.dist from project root

Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-10-21 02:08:33 +00:00
commit f6fac6c4bc
79758 changed files with 10547827 additions and 0 deletions

167
app/RSpade/Core/API.php Executable file
View File

@@ -0,0 +1,167 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core;
use App\RSpade\Core\Dispatch\ApiHandler;
use App\RSpade\Core\Manifest\Manifest;
/**
* API helper class for internal API execution
*
* Provides static methods for executing API endpoints internally
* without making HTTP requests.
*/
class API
{
/**
* Execute an API endpoint internally
*
* @param string $endpoint Format: "ControllerClass.method" or "Namespace\Class.method"
* @param array $params Parameters to pass to the API method
* @param array $options Optional settings like authentication context
* @return mixed Raw PHP response (not JSON)
*
* @example
* // Simple internal API call
* $user = API::execute('UserApi.get_profile', ['user_id' => 123]);
*
* // With authentication context
* $data = API::execute('DataApi.export', [
* 'format' => 'csv'
* ], ['auth_token' => 'secret_key']);
*/
public static function execute($endpoint, array $params = [], array $options = [])
{
$api_handler = app(ApiHandler::class);
return $api_handler->execute_internal($endpoint, $params, $options);
}
/**
* Execute multiple API endpoints in parallel
*
* @param array $requests Array of [endpoint, params, options] arrays
* @return array Array of results keyed by endpoint
*
* @example
* $results = API::execute_batch([
* ['UserApi.get_profile', ['user_id' => 123]],
* ['PostApi.get_user_posts', ['user_id' => 123]],
* ['CommentApi.get_user_comments', ['user_id' => 123]]
* ]);
*/
public static function execute_batch(array $requests)
{
$api_handler = app(ApiHandler::class);
$results = [];
foreach ($requests as $request) {
$endpoint = $request[0];
$params = $request[1] ?? [];
$options = $request[2] ?? [];
try {
$results[$endpoint] = $api_handler->execute_internal($endpoint, $params, $options);
} catch (\Exception $e) {
$results[$endpoint] = [
'error' => true,
'message' => $e->getMessage()
];
}
}
return $results;
}
/**
* Check if an API endpoint exists
*
* @param string $endpoint
* @return bool
*/
public static function exists($endpoint)
{
try {
$api_handler = app(ApiHandler::class);
list($class, $method) = explode('.', $endpoint);
// Try to resolve the class using Manifest
if (!str_contains($class, '\\')) {
// Try to find the class by simple name
try {
$metadata = Manifest::php_get_metadata_by_class($class);
$class = $metadata['fqcn'];
} catch (\RuntimeException $e) {
// Try with common API namespaces
$namespaces = [
'App\\Http\\Controllers\\Api\\',
'App\\RSpade\\Api\\',
'App\\Api\\'
];
$found = false;
foreach ($namespaces as $namespace) {
try {
$metadata = Manifest::php_get_metadata_by_fqcn($namespace . $class);
$class = $metadata['fqcn'];
$found = true;
break;
} catch (\RuntimeException $e2) {
// Try next namespace
}
}
if (!$found) {
// Class not found in Manifest
shouldnt_happen("API class not found in Manifest: {$class}");
}
}
} else {
// Full namespace provided, verify it exists in Manifest
try {
$metadata = Manifest::php_get_metadata_by_fqcn($class);
$class = $metadata['fqcn'];
} catch (\RuntimeException $e) {
shouldnt_happen("API class not found in Manifest: {$class}");
}
}
return method_exists($class, $method);
} catch (\Exception $e) {
return false;
}
}
/**
* Get available API endpoints
*
* @return array List of available endpoints
*/
public static function get_endpoints()
{
$manifest = app(Manifest::class);
$manifest_data = $manifest->get_manifest();
$endpoints = [];
if (isset($manifest_data['routes']['api'])) {
foreach ($manifest_data['routes']['api'] as $pattern => $methods) {
foreach ($methods as $method => $handler) {
$endpoints[] = [
'endpoint' => $handler['class'] . '.' . $handler['method'],
'http_method' => $method,
'pattern' => $pattern
];
}
}
}
return $endpoints;
}
}

372
app/RSpade/Core/Ajax/Ajax.php Executable file
View File

@@ -0,0 +1,372 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Ajax;
use Exception;
use Illuminate\Http\Request;
use App\RSpade\Core\Ajax\Exceptions\AjaxAuthRequiredException;
use App\RSpade\Core\Ajax\Exceptions\AjaxFatalErrorException;
use App\RSpade\Core\Ajax\Exceptions\AjaxFormErrorException;
use App\RSpade\Core\Ajax\Exceptions\AjaxUnauthorizedException;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Response\Rsx_Response_Abstract;
// @FILE-SUBCLASS-01-EXCEPTION
/**
* Ajax - Unified AJAX handling system
*
* Consolidates all AJAX-related functionality:
* - Internal server-side API calls (internal method)
* - Browser AJAX request handling (handle_browser_request method)
* - Response mode management for error handlers
* - Future: External HTTP calls, WebSocket handling
*/
class Ajax
{
/**
* Flag to indicate AJAX response mode for error handlers
*/
protected static bool $ajax_response_mode = false;
/**
* Call an internal API method directly from PHP code
*
* Used for server-side code to invoke API methods without HTTP overhead.
* This is useful for internal service calls, background jobs, and testing.
*
* @param string $rsx_controller Controller name (e.g., 'User_Controller')
* @param string $rsx_action Action/method name (e.g., 'get_profile')
* @param array $params Parameters to pass to the method
* @param array $auth Authentication context (not yet implemented)
* @return mixed The filtered response from the API method
* @throws AjaxAuthRequiredException
* @throws AjaxUnauthorizedException
* @throws AjaxFormErrorException
* @throws AjaxFatalErrorException
* @throws Exception
*/
public static function internal($rsx_controller, $rsx_action, $params = [], $auth = [])
{
// Get manifest to find controller
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
$controller_class = null;
$file_info = null;
// Search for controller class in manifest
foreach ($manifest as $file_path => $info) {
// Skip non-PHP files or files without classes
if (!isset($info['class']) || !isset($info['fqcn'])) {
continue;
}
// Check if class name matches exactly (without namespace)
$class_basename = basename(str_replace('\\', '/', $info['fqcn']));
if ($class_basename === $rsx_controller) {
$controller_class = $info['fqcn'];
$file_info = $info;
break;
}
}
if (!$controller_class) {
throw new Exception("Controller class not found: {$rsx_controller}");
}
// Check if class exists
if (!class_exists($controller_class)) {
throw new Exception("Controller class does not exist: {$controller_class}");
}
// Check if it's a subclass of Rsx_Controller_Abstract
if (!Manifest::php_is_subclass_of($controller_class, \App\RSpade\Core\Controller\Rsx_Controller_Abstract::class)) {
throw new Exception("Controller {$controller_class} must extend Rsx_Controller_Abstract");
}
// Check if method exists and has Ajax_Endpoint attribute
if (!isset($file_info['public_static_methods'][$rsx_action])) {
throw new Exception("Method {$rsx_action} not found in controller {$controller_class}");
}
$method_info = $file_info['public_static_methods'][$rsx_action];
$has_ajax_endpoint = false;
// Check for Ajax_Endpoint attribute in method metadata
if (isset($method_info['attributes'])) {
foreach ($method_info['attributes'] as $attr_name => $attr_data) {
// Check for Ajax_Endpoint with or without namespace
if ($attr_name === 'Ajax_Endpoint' ||
basename(str_replace('\\', '/', $attr_name)) === 'Ajax_Endpoint') {
$has_ajax_endpoint = true;
break;
}
}
}
if (!$has_ajax_endpoint) {
throw new Exception("Method {$rsx_action} in {$controller_class} must have Ajax_Endpoint annotation");
}
// Create a request object with the params
$request = Request::create('/_ajax/' . $rsx_controller . '/' . $rsx_action, 'POST', $params);
$request->setMethod('POST');
// Call pre_dispatch if it exists
$response = null;
if (method_exists($controller_class, 'pre_dispatch')) {
$response = $controller_class::pre_dispatch($request, $params);
}
// If pre_dispatch returned something, use that as response
if ($response === null) {
// Call the actual method
$response = $controller_class::$rsx_action($request, $params);
}
// Handle special response types
if ($response instanceof Rsx_Response_Abstract) {
return static::_handle_special_response($response);
}
// For normal responses, filter through JSON encode/decode to remove PHP objects
// This ensures we only return plain data structures
$filtered_response = json_decode(json_encode($response), true);
return $filtered_response;
}
/**
* Handle special response types by throwing appropriate exceptions
*
* @param Rsx_Response_Abstract $response
* @throws AjaxAuthRequiredException
* @throws AjaxUnauthorizedException
* @throws AjaxFormErrorException
* @throws AjaxFatalErrorException
*/
protected static function _handle_special_response(Rsx_Response_Abstract $response)
{
$type = $response->get_type();
$reason = $response->get_reason();
$details = $response->get_details();
switch ($type) {
case 'response_auth_required':
throw new AjaxAuthRequiredException($reason);
case 'response_unauthorized':
throw new AjaxUnauthorizedException($reason);
case 'response_form_error':
throw new AjaxFormErrorException($reason, $details);
case 'response_fatal_error':
$message = $reason;
if (!empty($details)) {
$message .= ' - ' . json_encode($details);
}
throw new AjaxFatalErrorException($message);
default:
throw new Exception("Unknown RSX response type: {$type}");
}
}
/**
* Handle incoming AJAX requests from the browser
*
* This method processes /_ajax/:controller/:action requests and returns JSON responses.
* It validates Ajax_Endpoint annotations and handles special response types.
*
* @param Request $request The incoming HTTP request
* @param array $params Route parameters including controller and action
* @return \Illuminate\Http\JsonResponse
* @throws Exception
*/
public static function handle_browser_request(Request $request, array $params = [])
{
// Enable AJAX response mode for error handlers
static::set_ajax_response_mode(true);
// Disable console debug HTML output for AJAX requests
\App\RSpade\Core\Debug\Debugger::disable_console_html_output();
// Get controller and action from params
$controller_name = $params['controller'] ?? null;
$action_name = $params['action'] ?? null;
if (!$controller_name || !$action_name) {
throw new Exception('Missing controller or action parameter');
}
// Use manifest to find the controller class
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
$controller_class = null;
$file_info = null;
// Search for controller class in manifest
foreach ($manifest as $file_path => $info) {
// Skip non-PHP files or files without classes
if (!isset($info['class']) || !isset($info['fqcn'])) {
continue;
}
// Check if class name matches exactly (without namespace)
$class_basename = basename(str_replace('\\', '/', $info['fqcn']));
if ($class_basename === $controller_name) {
$controller_class = $info['fqcn'];
$file_info = $info;
break;
}
}
if (!$controller_class) {
throw new Exception("Controller class not found: {$controller_name}");
}
// Check if class exists
if (!class_exists($controller_class)) {
throw new Exception("Controller class does not exist: {$controller_class}");
}
// Check if it's a subclass of Rsx_Controller_Abstract
if (!Manifest::php_is_subclass_of($controller_class, \App\RSpade\Core\Controller\Rsx_Controller_Abstract::class)) {
throw new Exception("Controller {$controller_class} must extend Rsx_Controller_Abstract");
}
// Check if method exists and has Ajax_Endpoint attribute
if (!isset($file_info['public_static_methods'][$action_name])) {
throw new Exception("Method {$action_name} not found in controller {$controller_class} public static methods");
}
$method_info = $file_info['public_static_methods'][$action_name];
$has_ajax_endpoint = false;
// Check for Ajax_Endpoint attribute in method metadata
if (isset($method_info['attributes'])) {
foreach ($method_info['attributes'] as $attr_name => $attr_data) {
// Check for Ajax_Endpoint with or without namespace
if ($attr_name === 'Ajax_Endpoint' ||
basename(str_replace('\\', '/', $attr_name)) === 'Ajax_Endpoint') {
$has_ajax_endpoint = true;
break;
}
}
}
if (!$has_ajax_endpoint) {
throw new Exception("Method {$action_name} in {$controller_class} must have Ajax_Endpoint annotation");
}
// Call pre_dispatch if it exists
$response = null;
if (method_exists($controller_class, 'pre_dispatch')) {
$response = $controller_class::pre_dispatch($request, $params);
}
// If pre_dispatch returned something, use that as response
if ($response === null) {
// Call the actual method
$response = $controller_class::$action_name($request, $params);
}
// Handle special response types
if ($response instanceof \App\RSpade\Core\Response\Rsx_Response_Abstract) {
return static::_handle_browser_special_response($response);
}
// Wrap normal response in success JSON
$json_response = [
'success' => true,
'_ajax_return_value' => $response,
];
// Add console debug messages if any
$console_messages = \App\RSpade\Core\Debug\Debugger::_get_console_messages();
if (!empty($console_messages)) {
// Messages are now structured as [channel, [arguments]]
$json_response['console_debug'] = $console_messages;
}
return response()->json($json_response);
}
/**
* Handle special response types for browser AJAX requests
*
* @param \App\RSpade\Core\Response\Rsx_Response_Abstract $response
* @return \Illuminate\Http\JsonResponse
*/
protected static function _handle_browser_special_response(\App\RSpade\Core\Response\Rsx_Response_Abstract $response)
{
$type = $response->get_type();
// Handle fatal error - always throw exception
if ($type === 'response_fatal_error') {
$details = $response->get_details();
$message = $response->get_reason();
if (!empty($details)) {
$message .= ' - ' . json_encode($details);
}
throw new Exception($message);
}
// Build error response based on type
$json_response = [
'success' => false,
'error_type' => $type,
'reason' => $response->get_reason(),
];
// Add details for form errors
if ($type === 'response_form_error') {
$json_response['details'] = $response->get_details();
}
// Add console debug messages if any
$console_messages = \App\RSpade\Core\Debug\Debugger::_get_console_messages();
if (!empty($console_messages)) {
// Messages are now structured as [channel, [arguments]]
$json_response['console_debug'] = $console_messages;
}
return response()->json($json_response);
}
/**
* Set AJAX response mode for error handlers
*
* When enabled, error handlers will return JSON instead of HTML
*
* @param bool $enabled
* @return void
*/
public static function set_ajax_response_mode(bool $enabled): void
{
static::$ajax_response_mode = $enabled;
}
/**
* Check if AJAX response mode is enabled
*
* @return bool
*/
public static function is_ajax_response_mode(): bool
{
return static::$ajax_response_mode;
}
/**
* Backward compatibility alias for internal()
* @deprecated Use internal() instead
*/
public static function call($rsx_controller, $rsx_action, $params = [], $auth = [])
{
return static::internal($rsx_controller, $rsx_action, $params, $auth);
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\RSpade\Core\Ajax;
use Illuminate\Http\Request;
use App\RSpade\Core\Ajax\Ajax;
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
/**
* Ajax_Batch_Controller - Handles batched Ajax requests
*
* This controller receives multiple Ajax calls in a single HTTP request
* and executes them individually, returning all results in one response.
*
* Request format:
* POST /_ajax/_batch
* {
* "batch_calls": "[{call_id: 0, controller: 'User_Controller', action: 'get_profile', params: {...}}, ...]"
* }
*
* Response format:
* {
* "C_0": {success: true, _ajax_return_value: {...}},
* "C_1": {success: false, error_type: "...", reason: "..."},
* ...
* }
*/
class Ajax_Batch_Controller extends Rsx_Controller_Abstract
{
/**
* Handle a batch of Ajax calls
*
* @param Request $request
* @param array $params
* @return \Illuminate\Http\JsonResponse
*/
#[Auth('Permission::anybody()')]
#[Route('/_ajax/_batch', methods: ['POST'])]
public static function batch(Request $request, array $params = [])
{
// Enable AJAX response mode
Ajax::set_ajax_response_mode(true);
// Disable console debug HTML output
\App\RSpade\Core\Debug\Debugger::disable_console_html_output();
// Get batch calls from request
$batch_calls_json = $request->input('batch_calls');
if (empty($batch_calls_json)) {
return response()->json([
'error' => 'Missing batch_calls parameter'
], 400);
}
// Parse batch calls
$batch_calls = json_decode($batch_calls_json, true);
if (!is_array($batch_calls)) {
return response()->json([
'error' => 'Invalid batch_calls format - must be JSON array'
], 400);
}
// Process each call
$responses = [];
foreach ($batch_calls as $call) {
$call_id = $call['call_id'] ?? null;
$controller = $call['controller'] ?? null;
$action = $call['action'] ?? null;
$call_params = $call['params'] ?? [];
if ($call_id === null || !$controller || !$action) {
$responses["C_{$call_id}"] = [
'success' => false,
'error_type' => 'invalid_call',
'reason' => 'Missing required fields: call_id, controller, or action',
];
continue;
}
try {
// Make the Ajax call using Ajax::internal()
$result = Ajax::internal($controller, $action, $call_params);
// Build success response
$response = [
'success' => true,
'_ajax_return_value' => $result,
];
// Add console debug messages if any
$console_messages = \App\RSpade\Core\Debug\Debugger::_get_console_messages();
if (!empty($console_messages)) {
$response['console_debug'] = $console_messages;
}
$responses["C_{$call_id}"] = $response;
} catch (Exceptions\AjaxAuthRequiredException $e) {
$responses["C_{$call_id}"] = [
'success' => false,
'error_type' => 'response_auth_required',
'reason' => $e->getMessage(),
];
} catch (Exceptions\AjaxUnauthorizedException $e) {
$responses["C_{$call_id}"] = [
'success' => false,
'error_type' => 'response_unauthorized',
'reason' => $e->getMessage(),
];
} catch (Exceptions\AjaxFormErrorException $e) {
$responses["C_{$call_id}"] = [
'success' => false,
'error_type' => 'response_form_error',
'reason' => $e->getMessage(),
'details' => $e->get_details(),
];
} catch (Exceptions\AjaxFatalErrorException $e) {
$responses["C_{$call_id}"] = [
'success' => false,
'error_type' => 'response_fatal_error',
'reason' => $e->getMessage(),
];
} catch (\Exception $e) {
// Generic exception handler
$responses["C_{$call_id}"] = [
'success' => false,
'error_type' => 'exception',
'reason' => $e->getMessage(),
];
}
}
return response()->json($responses);
}
}

264
app/RSpade/Core/Ajax/CLAUDE.md Executable file
View File

@@ -0,0 +1,264 @@
# Ajax - Unified AJAX Handling System
## Overview
The Ajax class consolidates all AJAX-related functionality in one place, providing clear methods for different types of AJAX operations:
- **Internal calls** - Server-side PHP calling API methods directly
- **Browser requests** - Handling incoming AJAX requests from JavaScript
- **Response modes** - Managing JSON vs HTML error responses
- **Future expansion** - External API calls, WebSocket handling
## Method Overview
### Core Methods
```php
// Internal server-side API calls (no HTTP overhead)
Ajax::internal($controller, $action, $params = [], $auth = [])
// Handle incoming browser AJAX requests (/_ajax route)
Ajax::handle_browser_request($request, $params)
// Set response mode for error handlers
Ajax::set_ajax_response_mode(bool $enabled)
// Check current response mode
Ajax::is_ajax_response_mode()
// Backward compatibility (deprecated)
Ajax::call() // Use internal() instead
```
## Usage Examples
### Internal Server-Side Calls
```php
use App\RSpade\Core\Ajax\Ajax;
// Call an API method internally from PHP code
$result = Ajax::internal('User_Controller', 'get_profile', ['user_id' => 123]);
```
This is useful for:
- Service-to-service communication within the application
- Background jobs that need to call API methods
- Testing API methods without HTTP overhead
- Batch processing operations
## How It Works
1. **Controller Discovery**: Uses the Manifest to find the controller class by name
2. **Validation**: Verifies that:
- The controller exists and extends `Rsx_Controller`
- The method exists and has the `Ajax_Endpoint` annotation
3. **Execution**:
- Calls `pre_dispatch()` if it exists on the controller
- If pre_dispatch returns null, calls the actual method
4. **Response Handling**:
- Normal responses are filtered through JSON encode/decode to remove PHP objects
- Special response types throw specific exceptions
## Browser AJAX Requests
The `/_ajax/:controller/:action` route is handled by `Internal_Api::dispatch()`, which delegates to `Ajax::handle_browser_request()`. This provides a consistent JSON response format for browser JavaScript code.
### Calling from JavaScript
The RSX framework automatically generates JavaScript stub classes for PHP controllers with `Ajax_Endpoint` methods. This enables clean, type-hinted API calls from JavaScript:
```javascript
// Auto-generated stub enables this clean syntax:
const result = await Demo_Index_Controller.hello_world();
console.log(result); // "Hello, world!"
// With parameters:
const profile = await User_Controller.get_profile(123);
console.log(profile.name);
```
The JavaScript stubs are:
- Auto-generated during manifest build for any controller with `Ajax_Endpoint` methods
- Included automatically in bundles when the PHP controller is referenced
- Use the `Ajax.call()` method internally to make the actual AJAX request
- Support arbitrary parameters via rest parameters (`...args`)
Behind the scenes, the stub translates to:
```javascript
class Demo_Index_Controller {
static async hello_world(...args) {
return Ajax.call('Demo_Index_Controller', 'hello_world', args);
}
}
```
### Response Format
Success responses:
```json
{
"success": true,
"_ajax_return_value": { /* method return value */ },
"console_debug": [ /* optional debug messages */ ]
}
```
Error responses:
```json
{
"success": false,
"error_type": "response_form_error",
"reason": "Validation failed",
"details": { /* error details */ }
}
```
## Exception Handling
For **internal calls** (`Ajax::internal()`), the method throws specific exceptions for different error scenarios:
### Authentication Required
```php
use App\RSpade\Core\Ajax\Exceptions\AjaxAuthRequiredException;
try {
$result = Ajax::internal('Protected_Controller', 'secure_method');
} catch (AjaxAuthRequiredException $e) {
// Handle authentication requirement
echo "Auth required: " . $e->getMessage();
}
```
### Unauthorized Access
```php
use App\RSpade\Core\Ajax\Exceptions\AjaxUnauthorizedException;
try {
$result = Ajax::internal('Admin_Controller', 'admin_action');
} catch (AjaxUnauthorizedException $e) {
// Handle authorization failure
echo "Unauthorized: " . $e->getMessage();
}
```
### Form Validation Errors
```php
use App\RSpade\Core\Ajax\Exceptions\AjaxFormErrorException;
try {
$result = Ajax::internal('User_Controller', 'update_profile', $data);
} catch (AjaxFormErrorException $e) {
// Extract error details
$message = $e->getMessage();
$details = $e->get_details(); // Array of validation errors
foreach ($details as $field => $error) {
echo "Field $field: $error\n";
}
}
```
### Fatal Errors
```php
use App\RSpade\Core\Ajax\Exceptions\AjaxFatalErrorException;
try {
$result = Ajax::internal('Some_Controller', 'problematic_method');
} catch (AjaxFatalErrorException $e) {
// Handle fatal error
error_log("Fatal error: " . $e->getMessage());
}
```
## Creating API Methods
To make a method callable via Ajax::internal(), it must:
1. Be in a controller that extends `Rsx_Controller`
2. Have the `Ajax_Endpoint` annotation
3. Accept `Request $request` and `array $params = []` as parameters
Example:
```php
class User_Controller extends Rsx_Controller
{
#[Ajax_Endpoint]
public static function get_profile(Request $request, array $params = [])
{
$user_id = $params['user_id'] ?? null;
if (!$user_id) {
return response_form_error('User ID required', ['user_id' => 'Missing']);
}
$user = User::find($user_id);
if (!$user) {
return response_form_error('User not found', ['user_id' => 'Invalid']);
}
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email
];
}
}
```
## Response Filtering
Normal responses (arrays, objects) are automatically filtered through `json_decode(json_encode())` to:
- Remove PHP object instances
- Convert objects to associative arrays
- Ensure only serializable data is returned
This prevents memory leaks and ensures clean data structures.
## Special Response Types
The following helper functions create special responses that are handled differently:
- `response_auth_required($reason, $redirect)` - Throws `AjaxAuthRequiredException`
- `response_unauthorized($reason, $redirect)` - Throws `AjaxUnauthorizedException`
- `response_form_error($reason, $details)` - Throws `AjaxFormErrorException` with extractable details
- `response_fatal_error($reason, $details)` - Throws `AjaxFatalErrorException`
## Security Considerations
- **Internal calls** (`Ajax::internal()`) bypass HTTP middleware, so authentication/authorization must be handled in the controller methods
- **Browser requests** (`handle_browser_request()`) go through normal HTTP middleware
- Always validate input parameters in your API methods
- Use `pre_dispatch()` in controllers for common security checks
## Testing
The Ajax class is ideal for testing API methods:
```php
class UserApiTest extends TestCase
{
public function test_get_profile()
{
$result = Ajax::internal('User_Controller', 'get_profile', ['user_id' => 1]);
$this->assertArrayHasKey('id', $result);
$this->assertArrayHasKey('name', $result);
$this->assertArrayHasKey('email', $result);
}
public function test_get_profile_missing_user()
{
$this->expectException(AjaxFormErrorException::class);
Ajax::internal('User_Controller', 'get_profile', ['user_id' => 999999]);
}
}
```
## Limitations
- The `$auth` parameter is reserved for future implementation of authentication context switching
- Methods must be static (following the RSX pattern)
- Only works with controllers in the manifest scan directories

View File

@@ -0,0 +1,17 @@
<?php
namespace App\RSpade\Core\Ajax\Exceptions;
// @FILE-SUBCLASS-01-EXCEPTION
/**
* Exception thrown when an API method requires authentication
*/
#[Instantiatable]
class AjaxAuthRequiredException extends \Exception
{
public function __construct($message = "Authentication Required", $code = 401, \Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\RSpade\Core\Ajax\Exceptions;
// @FILE-SUBCLASS-01-EXCEPTION
/**
* Exception thrown when an API method encounters a fatal error
*/
#[Instantiatable]
class AjaxFatalErrorException extends \Exception
{
public function __construct($message = "Fatal error occurred", $code = 500, \Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\RSpade\Core\Ajax\Exceptions;
// @FILE-SUBCLASS-01-EXCEPTION
/**
* Exception thrown when an API method encounters form validation errors
*
* This exception carries additional metadata about the errors that can be
* extracted by the calling code.
*/
#[Instantiatable]
class AjaxFormErrorException extends \Exception
{
protected array $details;
public function __construct($message = "Form validation error", array $details = [], $code = 422, \Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
$this->details = $details;
}
/**
* Get the error details/metadata
*
* @return array
*/
public function get_details(): array
{
return $this->details;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\RSpade\Core\Ajax\Exceptions;
// @FILE-SUBCLASS-01-EXCEPTION
/**
* Exception thrown when an API method denies access due to authorization
*/
#[Instantiatable]
class AjaxUnauthorizedException extends \Exception
{
public function __construct($message = "Unauthorized", $code = 403, \Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@@ -0,0 +1,70 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Auth;
use Illuminate\Support\Facades\Hash;
use App\RSpade\Core\Session\Session;
/**
* RSX Authentication service
*
* Provides authentication logic for login/logout.
* All session management is handled by the Session class directly.
*/
class RsxAuth
{
/**
* Attempt to authenticate a user
*
* @param array $credentials
* @return bool
*/
public static function attempt(array $credentials)
{
$email = $credentials['email'] ?? null;
$password = $credentials['password'] ?? null;
if (!$email || !$password) {
return false;
}
// Use model without eager loading
$user = User_Model::where('email', $email)->first();
if (!$user || !Hash::check($password, $user->password)) {
return false;
}
return self::login($user);
}
/**
* Log in a user and create session
*
* @param User_Model $user
* @return bool
*/
public static function login(User_Model $user)
{
// Use Session to set the user (will create session if needed)
Session::set_user($user);
return true;
}
/**
* Log out the current user
*
* @return void
*/
public static function logout()
{
// Use Session to logout
Session::logout();
}
}

264
app/RSpade/Core/Autoloader.php Executable file
View File

@@ -0,0 +1,264 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core;
use RuntimeException;
use App\RSpade\Core\Manifest\Manifest;
class Autoloader
{
// Manifest is now static - no instance needed
/**
* Registered class aliases
*/
protected static $aliases = [];
/**
* Whether the autoloader is registered
*/
protected static $registered = false;
/**
* Whether aliases have been loaded
*/
protected static $aliases_loaded = false;
/**
* Cache of the autoloader class map from manifest
*/
protected static ?array $class_map = null;
/**
* Register the RSX autoloader
*/
public static function register()
{
if (self::$registered) {
return;
}
// Initialize static Manifest and aliases
Manifest::init();
static::__load_aliases();
// Register with SPL after Composer
spl_autoload_register([static::class, 'load'], true, false);
self::$registered = true;
console_debug('AUTOLOADER', 'RSX Autoloader has been registered');
}
/**
* Load a class
*/
public static function load($class)
{
// Remove leading backslash
$requested_class = ltrim($class, '\\');
// Extract simple class name (after last backslash)
$simple_name = substr(strrchr($requested_class, '\\'), 1) ?: $requested_class;
// Try to find the simple class name in the manifest
try {
$metadata = Manifest::php_get_metadata_by_class($simple_name);
// Load the file
$file_path = str_replace('\\', '/', $metadata['file']);
$absolute_path = base_path($file_path);
if (file_exists($absolute_path)) {
require_once $absolute_path;
// If the requested FQCN still doesn't exist but the actual class does,
// create an alias
$actual_fqcn = $metadata['fqcn'];
if (!class_exists($requested_class, false) &&
class_exists($actual_fqcn, false) &&
$requested_class !== $actual_fqcn) {
class_alias($actual_fqcn, $requested_class);
}
return true;
}
} catch (\RuntimeException $e) {
// Class not found in manifest by simple name
}
// Check for special case class patterns that need custom handling
// Check if it's an RSX class by namespace
if (strpos($requested_class, 'Rsx\\') === 0) {
return static::__load_rsx_namespaced_class($requested_class);
}
// Check if it's a non-namespaced RSX class (like Rsx_Controller_Abstract)
if (strpos($requested_class, 'Rsx_') === 0) {
return static::__load_rsx_base_class($requested_class);
}
// Check aliases
if (isset(static::$aliases[$requested_class])) {
return static::__load_aliased_class($requested_class);
}
// Check for non-namespaced classes in the autoloader class map
if (strpos($requested_class, '\\') === false) {
return static::__load_from_class_map($requested_class);
}
// Not our responsibility
return false;
}
/**
* Load an RSX namespaced class
*/
protected static function __load_rsx_namespaced_class($class)
{
// First, try to find the class in the manifest by full class name
$manifest_data = Manifest::get_all();
// Search through all PHP files in the manifest
foreach ($manifest_data as $file_path => $file_info) {
if (!isset($file_info['namespace']) || !isset($file_info['class'])) {
continue;
}
// Check if this is the class we're looking for
$full_class_name = $file_info['namespace'] . '\\' . $file_info['class'];
if ($full_class_name === $class) {
// Found it! Load the file (convert relative to absolute)
// Convert backslashes to forward slashes for path
$file_path = str_replace('\\', '/', $file_path);
$absolute_path = base_path($file_path);
if (file_exists($absolute_path)) {
require_once $absolute_path;
return true;
}
}
}
// Class not found in manifest - return false to let other autoloaders try
return false;
}
/**
* Load RSX base classes
*/
protected static function __load_rsx_base_class($class)
{
// Map of base classes to their locations
$base_classes = [
'Rsx_Controller_Abstract' => 'app/RSpade/Core/Base/Rsx_Controller_Abstract.php',
];
if (isset($base_classes[$class])) {
$file = base_path($base_classes[$class]);
if (file_exists($file)) {
require_once $file;
return true;
}
}
return false;
}
/**
* Load an aliased class
*/
protected static function __load_aliased_class($class)
{
$actual_class = static::$aliases[$class];
// Try to load the actual class
if (class_exists($actual_class, true)) {
class_alias($actual_class, $class);
return true;
}
return false;
}
/**
* Load a class from the autoloader class map
* @param string $class Simple class name without namespace
*/
protected static function __load_from_class_map($class)
{
// Load class map from manifest if not cached
if (static::$class_map === null) {
static::$class_map = Manifest::get_autoloader_class_map();
}
// Check if the class exists in the map
if (!isset(static::$class_map[$class])) {
return false;
}
$fqcns = static::$class_map[$class];
// If multiple FQCNs exist for this simple name, throw an error
if (count($fqcns) > 1) {
$error_msg = "Fatal error: Ambiguous class name '{$class}'. Multiple classes found:\n";
foreach ($fqcns as $fqcn) {
$error_msg .= " - {$fqcn}\n";
}
$error_msg .= 'Please use the fully qualified class name (FQCN) to resolve ambiguity.';
throw new RuntimeException($error_msg);
}
// Single FQCN found, try to load it
$fqcn = $fqcns[0];
// Try to autoload the actual class
if (class_exists($fqcn, true) || interface_exists($fqcn, true) || trait_exists($fqcn, true)) {
// Create an alias for the simple name
class_alias($fqcn, $class);
return true;
}
return false;
}
/**
* Load class aliases from configuration
*/
protected static function __load_aliases()
{
if (static::$aliases_loaded) {
return;
}
// Future: Load from config/rsx.php
static::$aliases = [
// Example: 'UserModel' => 'Rsx\Models\User_Model'
];
static::$aliases_loaded = true;
}
/**
* Refresh the manifest and retry loading
*/
public static function refresh_and_retry($class)
{
// // In development mode, rebuild manifest if class not found
// if (config('app.env') === 'local') {
// Manifest::rebuild();
// return static::load($class);
// }
return false;
}
}

View File

@@ -0,0 +1,51 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Base;
use Illuminate\Http\Request;
/**
* Main_Abstract - Base class for application-wide middleware-style hooks
*
* Classes extending this abstract can define application-wide behaviors
* that execute at key points in the request lifecycle.
*/
abstract class Main_Abstract
{
/**
* Initialize the Main class
*
* Called once during application bootstrap
*
* @return void
*/
abstract public static function init();
/**
* Pre-dispatch hook
*
* Called before any route dispatch. If a non-null value is returned,
* dispatch is halted and that value is returned as the response.
*
* @param Request $request The current request
* @param array $params Combined GET values and URL parameters
* @return mixed|null Return null to continue, or a response to halt dispatch
*/
abstract public static function pre_dispatch(Request $request, array $params);
/**
* Unhandled route hook
*
* Called when no route matches the request
*
* @param Request $request The current request
* @param array $params Combined GET values and URL parameters
* @return mixed|null Return null for default 404, or a response to handle
*/
abstract public static function unhandled_route(Request $request, array $params);
}

View File

@@ -0,0 +1,247 @@
<?php
namespace App\RSpade\Core\Bootstrap;
use Exception;
use RuntimeException;
use App\RSpade\Core\Locks\RsxLocks;
/**
* RsxBootstrap - Early initialization for the RSpade framework
*
* This class handles critical early initialization tasks that must occur
* before the manifest or other systems are loaded. Most importantly, it
* acquires the global application read lock that ensures we can coordinate
* with other processes for exclusive operations like manifest rebuilding.
*/
class RsxBootstrap
{
/**
* The global application lock token
* @var string|null
*/
private static ?string $application_lock_token = null;
/**
* Whether bootstrap has been initialized
* @var bool
*/
private static bool $initialized = false;
/**
* Initialize the RSpade framework
*
* This MUST be called as early as possible in the application lifecycle,
* ideally in the bootstrap/app.php file right after Laravel boots.
*
* Acquires a global READ lock for the application that can be upgraded
* to WRITE when exclusive operations like manifest rebuilding are needed.
*
* @return void
*/
public static function initialize(): void
{
if (self::$initialized) {
return;
}
// Acquire global application read lock
// This ensures we can coordinate with other processes
self::__acquire_application_lock();
// Register shutdown handler to release lock
register_shutdown_function([self::class, 'shutdown']);
self::$initialized = true;
}
/**
* Acquire the global application lock
*
* All PHP processes acquire a READ lock by default.
* Artisan commands and always_write_lock mode use WRITE lock.
* This can be upgraded to WRITE for exclusive operations.
*
* FPC clients (Playwright spawned for SSR cache generation) skip lock acquisition
* to avoid deadlock with the parent request that spawned them.
*
* @return void
*/
private static function __acquire_application_lock(): void
{
// Skip lock acquisition for FPC clients to avoid deadlock
// FPC clients are identified by X-RSpade-FPC-Client: 1 header
if (isset($_SERVER['HTTP_X_RSPADE_FPC_CLIENT']) && $_SERVER['HTTP_X_RSPADE_FPC_CLIENT'] === '1') {
console_debug('CONCURRENCY', 'Skipping application lock for FPC client');
return;
}
$always_write = config('rsx.locking.always_write_lock', false);
// Detect artisan commands by checking if running from CLI and the script name contains 'artisan'
$is_artisan = php_sapi_name() === 'cli' &&
isset($_SERVER['argv'][0]) &&
strpos($_SERVER['argv'][0], 'artisan') !== false;
// Determine lock type
// Artisan commands always get write lock
// Or if always_write_lock is enabled
$lock_type = ($is_artisan || $always_write) ? RsxLocks::WRITE_LOCK : RsxLocks::READ_LOCK;
// Issue warning if always_write_lock is enabled in production CLI mode
if ($always_write && php_sapi_name() === 'cli' && app()->environment('production')) {
fwrite(STDERR, "\033[33mWARNING: RSX_ALWAYS_WRITE_LOCK is enabled in production. " .
'ALL requests are serialized with exclusive write lock. ' .
"This severely impacts performance and should only be used for critical operations.\033[0m\n");
}
console_debug('CONCURRENCY', "Acquiring global application '" . ($lock_type == RsxLocks::WRITE_LOCK ? 'WRITE' : 'READ') . "'lock");
try {
self::$application_lock_token = RsxLocks::get_lock(
RsxLocks::SERVER_LOCK,
RsxLocks::LOCK_APPLICATION,
$lock_type,
config('rsx.locking.timeout', 30)
);
} catch (Exception $e) {
// If we can't acquire the lock, the application cannot proceed
shouldnt_happen(
"Failed to acquire application {$lock_type} lock: " . $e->getMessage() . "\n" .
'This likely means another process has an exclusive lock.'
);
}
console_debug('CONCURRENCY', 'Global application lock acquired');
// Check if log rotation is needed (development mode only)
// TODO: cant log rotate before getting build key
// We logrotate somewhere else, dont we?
// if (env('APP_ENV') !== 'production') {
// $current_hash = Manifest::get_build_key();
// $version_file = storage_path('rsx-tmp/log_version');
// $should_rotate = false;
// // Ensure directory exists
// $dir = dirname($version_file);
// if (!is_dir($dir)) {
// @mkdir($dir, 0755, true);
// }
// // Check if version file exists and compare hash
// if (file_exists($version_file)) {
// $stored_hash = trim(file_get_contents($version_file));
// if ($stored_hash !== $current_hash) {
// $should_rotate = true;
// }
// } else {
// // File doesn't exist, first run
// $should_rotate = true;
// }
// // Rotate logs if needed
// if ($should_rotate) {
// Debugger::logrotate();
// // Update version file with new hash
// file_put_contents($version_file, $current_hash);
// }
// }
}
/**
* Upgrade the application lock to exclusive write mode
*
* Used when performing operations that require exclusive access,
* such as manifest rebuilding or bundle compilation.
*
* @return string New lock token after upgrade
* @throws RuntimeException if upgrade fails
*/
public static function upgrade_to_write_lock(): string
{
if (!self::$application_lock_token) {
shouldnt_happen('Cannot upgrade lock - no application lock held');
}
try {
$new_token = RsxLocks::upgrade_lock(
self::$application_lock_token,
config('rsx.locking.timeout', 30)
);
self::$application_lock_token = $new_token;
return $new_token;
} catch (Exception $e) {
throw new RuntimeException(
'Failed to upgrade application lock to write mode: ' . $e->getMessage()
);
}
}
/**
* Get the current application lock token
*
* @return string|null
*/
public static function get_application_lock_token(): ?string
{
return self::$application_lock_token;
}
/**
* Check if we hold the application lock
*
* @return bool
*/
public static function has_application_lock(): bool
{
return self::$application_lock_token !== null;
}
/**
* Shutdown handler - releases locks
*
* @return void
*/
public static function shutdown(): void
{
if (self::$application_lock_token) {
try {
RsxLocks::release_lock(self::$application_lock_token);
} catch (Exception $e) {
// Ignore errors during shutdown
} finally {
self::$application_lock_token = null;
}
}
}
/**
* Force release of application lock (emergency use only)
*
* @return void
*/
public static function force_release(): void
{
if (self::$application_lock_token) {
RsxLocks::release_lock(self::$application_lock_token);
self::$application_lock_token = null;
}
}
/**
* Temporarily release application lock for testing
* Used by rsx:debug to prevent lock contention with Playwright
*
* @return void
*/
public static function temporarily_release_lock(): void
{
if (self::$application_lock_token) {
RsxLocks::release_lock(self::$application_lock_token);
self::$application_lock_token = null;
}
}
}

259
app/RSpade/Core/Build_Manager.php Executable file
View File

@@ -0,0 +1,259 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core;
use Illuminate\Support\Facades\File;
class Build_Manager
{
/**
* Base paths for different artifact types
*/
protected const PATHS = [
'build' => 'build',
'cache' => 'build/cache',
'temp' => 'build/temp',
];
/**
* Get the full path for a build artifact
*
* @param string $path Relative path within build directory
* @param string $type Type of artifact ('build', 'cache', 'temp')
* @return string
*/
public static function path($path = '', $type = 'build')
{
$base = base_path(self::PATHS[$type] ?? self::PATHS['build']);
if (empty($path)) {
return $base;
}
return $base . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR);
}
/**
* Get path for a build artifact
*/
public static function build($path = '')
{
return self::path($path, 'build');
}
/**
* Get path for a cache artifact
*/
public static function cache($path = '')
{
return self::path($path, 'cache');
}
/**
* Get path for a temporary file
*/
public static function temp($path = '')
{
return self::path($path, 'temp');
}
/**
* Ensure a directory exists
*/
public static function ensure_directory($path)
{
$dir = is_dir($path) ? $path : dirname($path);
if (!File::isDirectory($dir)) {
File::makeDirectory($dir, 0755, true);
}
return $path;
}
/**
* Write content to a build file
*/
public static function put($path, $content, $type = 'build')
{
$full_path = self::path($path, $type);
self::ensure_directory($full_path);
return File::put($full_path, $content);
}
/**
* Get content from a build file
*/
public static function get($path, $type = 'build')
{
$full_path = self::path($path, $type);
if (File::exists($full_path)) {
return File::get($full_path);
}
return null;
}
/**
* Check if a build file exists
*/
public static function exists($path, $type = 'build')
{
return File::exists(self::path($path, $type));
}
/**
* Clear all files in a directory
*/
public static function clear($type = 'cache', $older_than_hours = null)
{
$path = self::path('', $type);
if (!File::isDirectory($path)) {
return 0;
}
$count = 0;
$now = time();
$cutoff = $older_than_hours ? $now - ($older_than_hours * 3600) : 0;
// Get all files recursively
$files = File::allFiles($path);
foreach ($files as $file) {
// Skip if checking age and file is too new
if ($cutoff > 0 && $file->getMTime() > $cutoff) {
continue;
}
File::delete($file->getPathname());
$count++;
}
// Clean up empty directories
self::__clean_empty_directories($path);
return $count;
}
/**
* Clear build artifacts
*/
public static function clear_build($subdirectory = null)
{
if ($subdirectory) {
$path = self::build($subdirectory);
if (File::isDirectory($path)) {
File::deleteDirectory($path);
return true;
}
return false;
}
return self::clear('build');
}
/**
* Clear cache files
*/
public static function clear_cache()
{
return self::clear('cache');
}
/**
* Clear temporary files
*/
public static function clear_temp($older_than_hours = null)
{
return self::clear('temp', $older_than_hours);
}
/**
* Clean up empty directories recursively
*/
protected static function __clean_empty_directories($path)
{
if (!File::isDirectory($path)) {
return;
}
$is_empty = true;
foreach (File::directories($path) as $directory) {
self::clean_empty_directories($directory);
if (File::exists($directory)) {
$is_empty = false;
}
}
if ($is_empty && count(File::files($path)) === 0) {
// Don't delete the base directories
if (!in_array($path, [
self::path('', 'build'),
self::path('', 'cache'),
self::path('', 'temp')
])) {
File::deleteDirectory($path);
}
}
}
/**
* Get statistics about build directories
*/
public static function stats()
{
$stats = [];
foreach (self::PATHS as $type => $path) {
$full_path = base_path($path);
if (File::isDirectory($full_path)) {
$files = File::allFiles($full_path);
$size = 0;
foreach ($files as $file) {
$size += $file->getSize();
}
$stats[$type] = [
'files' => count($files),
'size' => $size,
'size_human' => self::format_bytes($size)
];
} else {
$stats[$type] = [
'files' => 0,
'size' => 0,
'size_human' => '0 B'
];
}
}
return $stats;
}
/**
* Format bytes to human readable
*/
public static function format_bytes($bytes, $precision = 2)
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, $precision) . ' ' . $units[$pow];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
<?php
namespace App\RSpade\Core\Bundle;
/**
* Bundle_Integration_Abstract - Base class for external integrations into the RSX framework
*
* This abstract class defines the contract for integrations (plugins) that extend
* the framework's capabilities. Each integration can provide:
* - File type handlers for the Manifest system
* - Asset modules for the Bundle system
* - File processors for transformation during bundling
* - Custom file extensions to be discovered
*
* EXAMPLE IMPLEMENTATIONS:
* - Jqhtml_BundleIntegration: Adds .jqhtml template support
* - TypeScriptIntegration: Adds TypeScript compilation
* - VueIntegration: Adds .vue single-file component support
*
* REGISTRATION:
* Integrations are registered via service providers that call the appropriate
* registration methods on ExtensionRegistry, BundleCompiler, etc.
*/
abstract class BundleIntegration_Abstract
{
/**
* Get the integration's unique identifier
*
* This should be a short, lowercase string that uniquely identifies
* the integration (e.g., 'jqhtml', 'typescript', 'vue')
*
* @return string Integration identifier
*/
abstract public static function get_name(): string;
/**
* Get file extensions handled by this integration
*
* Return an array of file extensions (without dots) that this
* integration handles. These will be registered with the ExtensionRegistry
* for discovery and processing.
*
* @return array File extensions (e.g., ['jqhtml', 'jqtpl'])
*/
abstract public static function get_file_extensions(): array;
/**
* Get the ManifestModule class for file discovery (default: null)
*
* Return the fully qualified class name of a ManifestModule that
* handles file discovery and metadata extraction for this integration's
* file types. Return null if no manifest processing is needed.
*
* @return string|null ManifestModule class name or null
*/
public static function get_manifest_module(): ?string
{
return null;
}
/**
* @deprecated BundleModule system is obsolete - use BundleProcessor instead
*
* This method is retained for backwards compatibility but always returns null.
* The BundleModule system has been replaced by BundleProcessor for all
* compilation and asset handling needs.
*
* @return null Always returns null
*/
public static function get_bundle_module(): ?string
{
return null; // BundleModule system is obsolete
}
/**
* Get the Processor class for file transformation (default: null)
*
* Return the fully qualified class name of a BundleProcessor that
* transforms this integration's file types during bundle compilation.
* Return null if no processing is needed.
*
* @return string|null BundleProcessor class name or null
*/
public static function get_processor(): ?string
{
return null;
}
/**
* Get configuration options for this integration (default: empty array)
*
* Return an array of configuration options that can be customized
* in config files or at runtime. These might include compiler options,
* feature flags, or other settings.
*
* @return array Configuration options with defaults
*/
public static function get_config(): array
{
return [];
}
/**
* Get priority for this integration (default: 1000)
*
* Lower values mean higher priority. This affects the order in which
* integrations are loaded and their processors are run.
*
* @return int Priority (default 1000)
*/
public static function get_priority(): int
{
return 1000;
}
/**
* Check if this integration is enabled (default: true)
*
* Return whether this integration should be active. This might check
* configuration settings, environment variables, or other conditions.
*
* @return bool True if enabled
*/
public static function is_enabled(): bool
{
return true;
}
/**
* Bootstrap the integration (default: no-op)
*
* Called when the integration is registered. Use this to perform any
* one-time setup, register additional services, or configure the environment.
* This is called after all core services are available.
*/
public static function bootstrap(): void
{
// Override in subclasses if needed
}
/**
* Get dependencies for this integration (default: empty array)
*
* Return an array of other integration names that must be loaded
* before this one. The framework will ensure proper loading order.
*
* @return array Integration names this depends on
*/
public static function get_dependencies(): array
{
return [];
}
/**
* Generate JavaScript stub files for manifest entries (default: no-op)
*
* Called during manifest building (Phase 5) to generate JavaScript
* stub files that provide IDE autocomplete and runtime functionality
* for PHP classes. Integrations can use this to create JS equivalents
* of controllers, models, or other PHP classes.
*
* @param array &$manifest_data The complete manifest data (passed by reference)
* @return void
*/
public static function generate_manifest_stubs(array &$manifest_data): void
{
// Override in subclasses to generate stubs
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\RSpade\Core\Bundle;
/**
* BundleProcessor_Abstract - Base class for file transformation processors
*
* Processors handle compilation and transformation of specific file types
* during bundle building. Examples include:
* - JQHTML templates JavaScript
* - SCSS/SASS CSS
* - TypeScript JavaScript
* - ES6+ ES5 (transpilation)
*
* SEPARATION OF CONCERNS:
* - Processors focus solely on file transformation
* - Processors modify bundle file arrays by reference
* - Bundles orchestrate the overall compilation
*
* STATIC DESIGN:
* All methods are static - processors are stateless transformation engines
*
* BY-REFERENCE PROCESSING:
* The process_batch() method receives the bundle files array by reference
* and modifies it directly by appending compiled output files
*
* EXAMPLE:
* class Jqhtml_BundleProcessor extends BundleProcessor_Abstract {
* public static function process_batch(array &$bundle_files): void {
* foreach ($bundle_files as $path) {
* if (pathinfo($path, PATHINFO_EXTENSION) === 'jqhtml') {
* $compiled = static::compile_template($path);
* $temp_file = static::_write_temp_file($compiled['js_code'], 'js');
* $bundle_files[] = $temp_file;
* }
* }
* }
* }
*/
abstract class BundleProcessor_Abstract
{
/**
* Get the processor's unique identifier
*
* @return string Processor name (e.g., 'jqhtml', 'less', 'typescript')
*/
abstract public static function get_name(): string;
/**
* Get file extensions this processor handles
*
* @return array Extensions without dots (e.g., ['jqhtml', 'jqtpl'])
*/
abstract public static function get_extensions(): array;
/**
* Process multiple files in batch
*
* Examines all files in the bundle and compiles those matching this processor's extensions.
* Compiled output files are appended to the bundle_files array.
* Uses file modification time caching - skips compilation if temp file is newer than source.
*
* @param array &$bundle_files Array of file paths (modified by reference)
* @return void
*/
abstract public static function process_batch(array &$bundle_files): void;
/**
* Pre-processing hook (default: no-op)
*
* Called before any files are processed. Can be used to initialize
* resources, validate configuration, or prepare the processing environment.
*
* @param array $all_files All files that will be processed
* @param array $options Processing options
*/
public static function before_processing(array $all_files, array $options = []): void
{
// Override in subclasses if needed
}
/**
* Post-processing hook (default: no additional files)
*
* Called after all files are processed. Can be used to generate
* additional output, clean up resources, or perform final transformations.
*
* @param array $processed_files Processed file results
* @param array $options Processing options
* @return array Additional files to include in bundle
*/
public static function after_processing(array $processed_files, array $options = []): array
{
return [];
}
/**
* Get processor configuration
*
* @return array Processor configuration from config/rsx.php
*/
public static function get_config(): array
{
$name = static::get_name();
return config("rsx.processors.{$name}", []);
}
/**
* Validate processor configuration (default: no validation)
*
* @throws \RuntimeException If configuration is invalid
*/
public static function validate(): void
{
// Override in subclasses to add validation
}
/**
* Get processor priority (default: 500)
*
* Processors with lower priority run first.
* This allows dependencies between processors.
*
* @return int Priority (0-1000, default 500)
*/
public static function get_priority(): int
{
$config = static::get_config();
return $config['priority'] ?? 500;
}
/**
* Get metadata for manifest integration (default: basic metadata)
*
* Return metadata that should be added to the manifest
* for files processed by this processor.
*
* @param string $file_path File being processed
* @param array $result Processing result
* @return array Metadata for manifest
*/
public static function get_manifest_metadata(string $file_path, array $result): array
{
return [
'processor' => static::get_name(),
'processed_at' => time(),
'type' => $result['type'] ?? 'unknown'
];
}
/**
* Helper: Read file contents safely
*/
protected static function _read_file(string $path): string
{
if (!file_exists($path)) {
throw new \RuntimeException("File not found: {$path}");
}
return file_get_contents($path);
}
/**
* Helper: Write to temp file
*/
protected static function _write_temp_file(string $content, string $extension = 'tmp'): string
{
$temp_dir = storage_path('rsx-tmp');
if (!is_dir($temp_dir)) {
mkdir($temp_dir, 0755, true);
}
$temp_file = $temp_dir . '/' . uniqid('processor_') . '.' . $extension;
file_put_contents($temp_file, $content);
return $temp_file;
}
/**
* Helper: Get cache key for processed file
*/
protected static function _get_cache_key(string $file_path): string
{
$content = file_get_contents($file_path);
$mtime = filemtime($file_path);
return md5($file_path . ':' . $mtime . ':' . $content);
}
}

View File

@@ -0,0 +1,39 @@
# Bundle Processor System
## Architecture
- **Extension-agnostic BundleCompiler**: Only handles .js and .css files directly
- **Global processor configuration**: Processors configured in `config/rsx.php` under `bundle_processors`
- **Processors receive ALL files**: Each processor examines all collected files and decides what to process
- **No per-bundle processor config**: Processors are globally enabled, not per-bundle
## How It Works
1. BundleCompiler collects ALL files from bundle includes (not just JS/CSS)
2. Each configured processor receives the full list of collected files
3. Processors decide which files to process based on extension or other criteria
4. Processed files replace or augment original files in the bundle
5. Final bundle contains JS and CSS files only
## Creating a Processor
```php
class MyProcessor extends AbstractBundleProcessor {
public static function get_name(): string { return 'myprocessor'; }
public static function get_extensions(): array { return ['myext']; }
public static function process(string $file_path, array $options = []): ?array {
// Transform file and return result or null to exclude
}
}
```
## Registering Processors
Add to `config/rsx.php`:
```php
'bundle_processors' => [
\App\RSpade\Processors\MyProcessor::class,
]
```
## SCSS @import Validation
- **Non-vendor files**: Cannot use @import directives (throws error)
- **Vendor files**: Files in directories named 'vendor' CAN use @import
- **Rationale**: @import bypasses bundle dependency management
- **Solution**: Include dependencies directly in bundle definition

View File

@@ -0,0 +1,29 @@
<?php
namespace App\RSpade\Core\Bundle;
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
/**
* Core Framework Bundle
*
* Provides all core JavaScript framework files that are required for RSX to function.
* This bundle is automatically included in every project.
*/
class Core_Bundle extends Rsx_Bundle_Abstract
{
/**
* Define the bundle configuration
*
* @return array Bundle configuration
*/
public static function define(): array
{
return [
'include' => [
__DIR__,
'app/RSpade/Core/Js',
],
];
}
}

View File

@@ -0,0 +1,701 @@
<?php
namespace App\RSpade\Core\Bundle;
use RuntimeException;
use App\RSpade\CodeQuality\RuntimeChecks\BundleErrors;
use App\RSpade\Core\Bundle\BundleCompiler;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Session\Session;
/**
* Rsx_Bundle_Abstract - Abstract base class for user-defined bundles
*
* Bundles are high-level asset collections that define what JavaScript, CSS,
* and other resources should be included in a page. The bundle system uses a
* unified 'include' array that automatically detects what each item is:
* - Module aliases (jquery, lodash, bootstrap5)
* - Bundle aliases (defined in config/rsx.php)
* - Bundle class names (e.g., Frontend_Bundle)
* - Bundle file paths (e.g., rsx/theme/bootstrap5_src_bundle.php)
* - Directory paths (e.g., rsx/app/frontend)
* - Regular file paths (e.g., rsx/theme/variables.scss)
*
* USAGE:
* 1. Create a bundle class that extends Rsx_Bundle_Abstract
* 2. Implement the define() method with an 'include' array
* 3. Call BundleName::render() in Blade views
*
* EXAMPLE:
* class Frontend_Bundle extends Rsx_Bundle_Abstract {
* public static function define(): array {
* return [
* 'include' => [
* 'jquery', // Module alias
* 'bootstrap5_src', // Bundle alias
* Common_Bundle::class, // Bundle class
* 'rsx/theme/variables.scss', // File
* 'rsx/app/frontend', // Directory
* ],
* ];
* }
* }
*/
abstract class Rsx_Bundle_Abstract
{
/**
* Define the bundle's assets
*
* Return an array with the following keys:
* - include: Array of items to include (auto-detected types)
* - config: Bundle-specific configuration (optional)
* - npm_modules: NPM modules to include (optional)
* - cdn_assets: Direct CDN assets (optional)
* - local_assets: Local assets not bundled (optional)
*
* The 'include' array accepts:
* - Module aliases: 'jquery', 'lodash', 'bootstrap5', 'jqhtml'
* - Bundle aliases: Defined in config/rsx.php bundle_aliases
* - Bundle classes: MyBundle::class or 'MyBundle'
* - Bundle files: 'path/to/bundle.php'
* - Directories: 'rsx/app/mymodule'
* - Files: 'rsx/theme/style.scss'
*
* @return array Bundle definition
*/
abstract public static function define(): array;
/**
* Track which bundle has been rendered for this request
* Format: ['bundle_class' => 'Bundle_Name', 'calling_location' => 'file:line']
*/
public static ?array $_has_rendered = null;
/**
* Render a bundle's HTML output
*
* @param array $options Rendering options
* @return string HTML output with script/link tags
*/
public static function render(array $options = []): string
{
// Get the calling class
$bundle_class = get_called_class();
// Fatal if called directly on Rsx_Bundle_Abstract
if ($bundle_class === __CLASS__ || $bundle_class === 'App\\RSpade\\Core\\Bundle\\Rsx_Bundle_Abstract') {
// Cannot call abstract class directly
BundleErrors::abstract_called_directly();
}
// Check if another bundle has already been rendered
if (Rsx_Bundle_Abstract::$_has_rendered !== null) {
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
$caller = $backtrace[0] ?? [];
$current_location = ($caller['file'] ?? 'unknown') . ':' . ($caller['line'] ?? '?');
throw new RuntimeException(
"Multiple bundle render attempted.\n\n" .
'Already rendered: ' . Rsx_Bundle_Abstract::$_has_rendered['bundle_class'] . "\n" .
'Called from: ' . Rsx_Bundle_Abstract::$_has_rendered['calling_location'] . "\n\n" .
'Attempted to render: ' . $bundle_class . "\n" .
'Called from: ' . $current_location . "\n\n" .
"Only one bundle is allowed per page. If you need to share code between bundles,\n" .
"include the shared bundle in your bundle's configuration:\n\n" .
"class Your_Bundle extends Rsx_Bundle_Abstract {\n" .
" public static function define(): array {\n" .
" return [\n" .
" 'include' => [\n" .
" Shared_Bundle::class, // Include another bundle\n" .
" 'your/specific/files',\n" .
" ]\n" .
" ];\n" .
" }\n" .
'}'
);
}
// Track this bundle render
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
$caller = $backtrace[0] ?? [];
Rsx_Bundle_Abstract::$_has_rendered = [
'bundle_class' => $bundle_class,
'calling_location' => ($caller['file'] ?? 'unknown') . ':' . ($caller['line'] ?? '?'),
];
// Dump any console debug messages before bundle output
\App\RSpade\Core\Debug\Debugger::dump_console_debug_messages_to_html();
// In development mode, validate path coverage
if (!app()->environment('production')) {
static::__validate_path_coverage($bundle_class);
}
$compiler = new BundleCompiler($options);
$compiled = $compiler->compile($bundle_class);
// Generate HTML output
return static::__generate_html($compiled, $options);
}
/**
* Get the bundle definition with resolved dependencies
*
* @param string $bundle_class Bundle class name
* @return array Resolved bundle definition
*/
public static function get_resolved_definition(string $bundle_class): array
{
// Get raw definition - verify class exists in Manifest
try {
// Check if this is a FQCN (contains backslash) or simple name
if (strpos($bundle_class, '\\') !== false) {
// It's a FQCN, use php_get_metadata_by_fqcn
$metadata = Manifest::php_get_metadata_by_fqcn($bundle_class);
} else {
// It's a simple name, use php_get_metadata_by_class
$metadata = Manifest::php_get_metadata_by_class($bundle_class);
}
if (!isset($metadata['extends']) || ($metadata['extends'] !== 'Rsx_Bundle_Abstract' && $metadata['extends'] !== 'RsxBundle')) {
// Check if it extends a class that extends Rsx_Bundle_Abstract
$extends_bundle = false;
$current_extends = $metadata['extends'] ?? null;
while ($current_extends && !$extends_bundle) {
if ($current_extends === 'Rsx_Bundle_Abstract' || $current_extends === 'RsxBundle') {
$extends_bundle = true;
} else {
try {
$parent_metadata = Manifest::php_get_metadata_by_class($current_extends);
$current_extends = $parent_metadata['extends'] ?? null;
} catch (RuntimeException $e) {
break;
}
}
}
if (!$extends_bundle) {
throw new RuntimeException("Class {$bundle_class} must extend Rsx_Bundle_Abstract");
}
}
} catch (RuntimeException $e) {
if (str_contains($e->getMessage(), 'not found in manifest')) {
throw new RuntimeException("Bundle class not found: {$bundle_class}");
}
throw $e;
}
$definition = $bundle_class::define();
// Base config that all bundles inherit
$base_config = [
'debug' => config('app.debug'),
'build_key' => Manifest::get_build_key(),
];
// Start with base config
$config = $base_config;
// Merge in bundle-specific config using array_merge_deep
if (!empty($definition['config'])) {
$config = array_merge_deep($config, $definition['config']);
}
// Normalize definition structure - simplified with unified 'include' array
$resolved = [
'include' => $definition['include'] ?? [],
'npm_modules' => $definition['npm_modules'] ?? [], // NPM modules support
'config' => $config,
'cdn_assets' => $definition['cdn_assets'] ?? [],
'local_assets' => $definition['local_assets'] ?? [],
];
return $resolved;
}
/**
* Generate HTML output for compiled bundle
*
* @param array $compiled Compiled bundle data
* @param array $options Rendering options
* @return string HTML output
*/
protected static function __generate_html(array $compiled, array $options = []): string
{
// Validate we have the expected structure
if (!is_array($compiled)) {
throw new RuntimeException('BundleCompiler returned non-array: ' . gettype($compiled));
}
// In development, we should have vendor/app split files
// In production, we should have combined files
$is_production = app()->environment('production');
if (!$is_production) {
// Development mode - expect vendor/app bundle paths
if (!isset($compiled['vendor_js_bundle_path']) &&
!isset($compiled['app_js_bundle_path']) &&
!isset($compiled['vendor_css_bundle_path']) &&
!isset($compiled['app_css_bundle_path'])) {
throw new RuntimeException('BundleCompiler missing expected vendor/app bundle paths. Got keys: ' . implode(', ', array_keys($compiled)));
}
} else {
// Production mode - expect combined bundle paths
if (!isset($compiled['js_bundle_path']) && !isset($compiled['css_bundle_path'])) {
throw new RuntimeException('BundleCompiler missing expected js/css bundle paths. Got keys: ' . implode(', ', array_keys($compiled)));
}
}
$html = [];
$manifest_hash = Manifest::get_build_key();
// Consolidate all data into window.rsxapp
$rsxapp_data = [];
// Add build_key (always included in both dev and production)
$rsxapp_data['build_key'] = $manifest_hash;
// Add bundle data if present
if (!empty($compiled['config'])) {
$rsxapp_data = array_merge($rsxapp_data, $compiled['bundle_data'] ?? []);
}
// Add runtime data
$rsxapp_data['debug'] = !app()->environment('production');
$rsxapp_data['current_controller'] = \App\RSpade\Core\Rsx::get_current_controller();
$rsxapp_data['current_action'] = \App\RSpade\Core\Rsx::get_current_action();
$rsxapp_data['is_auth'] = Session::is_logged_in();
$rsxapp_data['ajax_disable_batching'] = config('rsx.development.ajax_disable_batching', false);
// Add user, site, and csrf data from session
$rsxapp_data['user'] = Session::get_user();
$rsxapp_data['site'] = Session::get_site();
$rsxapp_data['csrf'] = Session::get_csrf_token();
// Add browser error logging flag (enabled in both dev and production)
if (config('rsx.log_browser_errors', false)) {
$rsxapp_data['log_browser_errors'] = true;
}
// Add console_debug config in non-production mode
if (!app()->environment('production')) {
$console_debug_config = config('rsx.console_debug', []);
// Build console_debug settings
$filter_mode = $console_debug_config['filter_mode'] ?? 'all';
// Get the appropriate channel list based on filter mode
$filter_channels = [];
if ($filter_mode === 'whitelist') {
$filter_channels = $console_debug_config['whitelist'] ?? [];
} elseif ($filter_mode === 'blacklist') {
$filter_channels = $console_debug_config['blacklist'] ?? [];
}
$console_debug = [
'enabled' => $console_debug_config['enabled'] ?? true,
'filter_mode' => $filter_mode,
'filter_channels' => $filter_channels,
'specific_channel' => $console_debug_config['specific_channel'] ?? null,
'include_timestamp' => $console_debug_config['include_timestamp'] ?? false,
'include_benchmark' => $console_debug_config['include_benchmark'] ?? false,
'include_location' => $console_debug_config['include_location'] ?? false,
'include_backtrace' => $console_debug_config['include_backtrace'] ?? false,
'outputs' => [
'browser' => ($console_debug_config['outputs']['web'] ?? true),
'laravel_log' => ($console_debug_config['outputs']['laravel_log'] ?? false),
],
];
// Check for Playwright test headers that override console_debug settings
// Only from loopback IPs without proxy headers
$request = request();
if ($request && $request->hasHeader('X-Playwright-Test') && is_loopback_ip()) {
// Check for disable header first
if ($request->hasHeader('X-Console-Debug-Disable')) {
$console_debug['enabled'] = false;
}
// Check for console debug filter header
if ($request->hasHeader('X-Console-Debug-Filter')) {
$filter = $request->header('X-Console-Debug-Filter');
if ($filter) {
$console_debug['filter_mode'] = 'specific';
$console_debug['specific_channel'] = strtoupper($filter);
}
}
// Check for benchmark header
if ($request->hasHeader('X-Console-Debug-Benchmark')) {
$console_debug['include_benchmark'] = true;
}
// Check for all channels header
if ($request->hasHeader('X-Console-Debug-All')) {
$console_debug['filter_mode'] = 'all';
$console_debug['specific_channel'] = null;
}
}
$rsxapp_data['console_debug'] = $console_debug;
}
// Pretty print JSON in non-production environments
$rsxapp_json = app()->environment('production')
? json_encode($rsxapp_data, JSON_UNESCAPED_SLASHES)
: json_encode($rsxapp_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$html[] = '<script>window.rsxapp = ' . $rsxapp_json . ';</script>';
// Sort CDN assets: jQuery first, then others in deterministic order
$cdn_css = $compiled['cdn_css'] ?? [];
$cdn_js = $compiled['cdn_js'] ?? [];
// Separate jQuery assets from others
$jquery_css = [];
$other_css = [];
foreach ($cdn_css as $asset) {
if (stripos($asset['url'], 'jquery') !== false) {
$jquery_css[] = $asset;
} else {
$other_css[] = $asset;
}
}
$jquery_js = [];
$other_js = [];
foreach ($cdn_js as $asset) {
if (stripos($asset['url'], 'jquery') !== false) {
$jquery_js[] = $asset;
} else {
$other_js[] = $asset;
}
}
// Sort other assets by URL for deterministic ordering
usort($other_css, function ($a, $b) {
return strcmp($a['url'], $b['url']);
});
usort($other_js, function ($a, $b) {
return strcmp($a['url'], $b['url']);
});
// Add CSS: jQuery first, then others
foreach (array_merge($jquery_css, $other_css) as $asset) {
$tag = '<link rel="stylesheet" href="' . htmlspecialchars($asset['url']) . '"';
if (!empty($asset['integrity'])) {
$tag .= ' integrity="' . htmlspecialchars($asset['integrity']) . '"';
$tag .= ' crossorigin="anonymous"';
}
$tag .= '>';
$html[] = $tag;
}
// Add JS: jQuery first, then others
foreach (array_merge($jquery_js, $other_js) as $asset) {
$tag = '<script src="' . htmlspecialchars($asset['url']) . '" defer';
if (!empty($asset['integrity'])) {
$tag .= ' integrity="' . htmlspecialchars($asset['integrity']) . '"';
$tag .= ' crossorigin="anonymous"';
}
$tag .= '></script>';
$html[] = $tag;
}
// Add CSS bundles
// In development mode with split bundles, add vendor then app
if (!empty($compiled['vendor_css_bundle_path']) || !empty($compiled['app_css_bundle_path'])) {
// Split bundles mode
if (!empty($compiled['vendor_css_bundle_path'])) {
$vendor_url = static::__get_bundle_url($compiled['vendor_css_bundle_path']);
$html[] = '<link rel="stylesheet" href="' . htmlspecialchars($vendor_url) . '">';
}
if (!empty($compiled['app_css_bundle_path'])) {
$app_url = static::__get_bundle_url($compiled['app_css_bundle_path']);
$html[] = '<link rel="stylesheet" href="' . htmlspecialchars($app_url) . '">';
}
} elseif (!empty($compiled['css_bundle_path'])) {
// Single bundle mode (production)
$url = static::__get_bundle_url($compiled['css_bundle_path']);
$html[] = '<link rel="stylesheet" href="' . htmlspecialchars($url) . '">';
}
// Add JS bundles
// In development mode with split bundles, add vendor then app
if (!empty($compiled['vendor_js_bundle_path']) || !empty($compiled['app_js_bundle_path'])) {
// Split bundles mode - vendor MUST load before app
if (!empty($compiled['vendor_js_bundle_path'])) {
$vendor_url = static::__get_bundle_url($compiled['vendor_js_bundle_path']);
$html[] = '<script src="' . htmlspecialchars($vendor_url) . '" defer></script>';
}
if (!empty($compiled['app_js_bundle_path'])) {
$app_url = static::__get_bundle_url($compiled['app_js_bundle_path']);
$html[] = '<script src="' . htmlspecialchars($app_url) . '" defer></script>';
}
} elseif (!empty($compiled['js_bundle_path'])) {
// Single bundle mode (production)
$url = static::__get_bundle_url($compiled['js_bundle_path']);
$html[] = '<script src="' . htmlspecialchars($url) . '" defer></script>';
}
// Inline JS removed - bundles handle their own initialization
return implode("\n", $html);
}
/**
* Get URL for a bundle file
*
* @param string $path Bundle file path
* @return string Bundle URL
*/
protected static function __get_bundle_url(string $path): string
{
// Extract filename from path
$filename = basename($path);
// Validate it's a compiled bundle file
// Format: BundleName__vendor.[hash].js/css or BundleName__app.[hash].js/css
if (preg_match('/^\w+__(vendor|app)\.[a-f0-9]{8}\.(js|css)$/', $filename)) {
// Use the controlled route for compiled assets
$url = '/_compiled/' . $filename;
// In development mode, add ?v= parameter for cache-busting
if (env('APP_ENV') !== 'production') {
$manifest_hash = Manifest::get_build_key();
$url .= '?v=' . $manifest_hash;
}
return $url;
}
// This shouldn't happen with properly compiled bundles
throw new RuntimeException("Invalid bundle file path: {$path}");
}
/**
* Resolve bundle class name from alias
*
* @param string $bundle Bundle class name or alias
* @return string Fully qualified bundle class name
*/
protected static function __resolve_bundle_class(string $bundle): string
{
// First try to find by exact class name in Manifest
try {
$metadata = Manifest::php_get_metadata_by_class($bundle);
return $metadata['fqcn'];
} catch (RuntimeException $e) {
// Not found by simple name
}
// Try with common namespace prefixes
$possible_names = [
$bundle,
"App\\Bundles\\{$bundle}",
"Rsx\\Bundles\\{$bundle}",
];
foreach ($possible_names as $name) {
try {
$metadata = Manifest::php_get_metadata_by_fqcn($name);
return $metadata['fqcn'];
} catch (RuntimeException $e) {
// Try next
}
}
// Search manifest for any class ending with the bundle name that extends Rsx_Bundle_Abstract
$manifest_data = Manifest::get_all();
foreach ($manifest_data as $file_info) {
if (isset($file_info['class']) &&
str_ends_with($file_info['class'], $bundle) &&
isset($file_info['extends']) &&
($file_info['extends'] === 'Rsx_Bundle_Abstract' || $file_info['extends'] === 'RsxBundle' ||
$file_info['extends'] === 'App\\RSpade\\Core\\Bundle\\Rsx_Bundle_Abstract' ||
$file_info['extends'] === 'App\\RSpade\\Core\\Bundle\\RsxBundle')) {
return $file_info['fqcn'];
}
}
throw new RuntimeException("Bundle class not found: {$bundle}");
}
/**
* Validate that the bundle's include paths cover the calling view/layout and controller
*
* @param string $bundle_class Fully qualified bundle class name
* @throws RuntimeException if bundle doesn't cover the calling file's directory or current controller
*/
protected static function __validate_path_coverage(string $bundle_class): void
{
// Try to get the view path from shared data (set by rsx_view helper)
$view = \Illuminate\Support\Facades\View::shared('rsx_current_view_path', null);
if (!$view) {
// View path required for bundle validation
BundleErrors::cannot_determine_view_path();
}
// Normalize the view path
$view_path = str_replace('\\', '/', $view);
if (!str_starts_with($view_path, '/')) {
// Convert to absolute path if needed
if (file_exists(base_path($view_path))) {
$view_path = base_path($view_path);
}
}
// Convert absolute path to relative from base_path
$base_path = str_replace('\\', '/', base_path());
if (str_starts_with($view_path, $base_path)) {
$view_path = substr($view_path, strlen($base_path) + 1);
}
// Get the bundle's definition to check include paths
$definition = static::get_resolved_definition($bundle_class);
$include_paths = $definition['include'] ?? [];
// If no include paths defined, skip validation (bundle might use explicit files)
if (empty($include_paths)) {
return;
}
// Convert any absolute paths in include_paths to relative
$base_path = str_replace('\\', '/', base_path());
// Determine project root (parent of system/ if we're in a subdirectory structure)
$project_root = $base_path;
if (basename($base_path) === 'system') {
$project_root = dirname($base_path);
}
foreach ($include_paths as &$include_path) {
$include_path = str_replace('\\', '/', $include_path);
if (str_starts_with($include_path, '/')) {
// If it starts with base_path, strip it
if (str_starts_with($include_path, $base_path)) {
$include_path = substr($include_path, strlen($base_path) + 1);
} elseif (str_starts_with($include_path, $project_root)) {
// Path is under project root but not under base_path (e.g., symlinked rsx/)
$include_path = substr($include_path, strlen($project_root) + 1);
} else {
// If it's an absolute path not under project root, try removing leading slash
$include_path = ltrim($include_path, '/');
}
}
}
unset($include_path); // Clear reference
// Check if any include path covers the view's directory
$view_dir = dirname($view_path);
$is_covered = false;
foreach ($include_paths as $include_path) {
// Normalize path for comparison
$include_path = rtrim($include_path, '/');
// Check if the include path is a file that matches the view
if (str_ends_with($include_path, '.php') || str_ends_with($include_path, '.scss') ||
str_ends_with($include_path, '.css') || str_ends_with($include_path, '.js')) {
// For files, check if it matches the view file itself
if ($view_path === $include_path) {
$is_covered = true;
break;
}
continue;
}
// For directories, check if view directory starts with or equals the include path
if ($view_dir === $include_path || str_starts_with($view_dir, $include_path . '/')) {
$is_covered = true;
break;
}
}
// Throw error if not covered
if (!$is_covered) {
// Bundle doesn't include the view directory
BundleErrors::view_not_covered($view_path, $view_dir, $bundle_class, $include_paths);
}
// Check if current controller (if set during route dispatch) is covered by bundle
$current_controller = \App\RSpade\Core\Rsx::get_current_controller();
$current_action = \App\RSpade\Core\Rsx::get_current_action();
// Only validate if we're in a route dispatch context (controller and action are set)
if ($current_controller && $current_action) {
// Look up the controller file in the manifest
try {
$controller_metadata = Manifest::php_get_metadata_by_class($current_controller);
$controller_file = $controller_metadata['file'] ?? null;
if ($controller_file) {
// Normalize controller path
$controller_path = str_replace('\\', '/', $controller_file);
if (str_starts_with($controller_path, $base_path)) {
$controller_path = substr($controller_path, strlen($base_path) + 1);
}
// Check if controller is covered by any include path
$controller_dir = dirname($controller_path);
$controller_covered = false;
foreach ($include_paths as $include_path) {
$include_path = rtrim($include_path, '/');
// Check if include path is a file matching the controller
if (str_ends_with($include_path, '.php')) {
if ($controller_path === $include_path) {
$controller_covered = true;
break;
}
continue;
}
// Check if controller directory is within include path
if ($controller_dir === $include_path || str_starts_with($controller_dir, $include_path . '/')) {
$controller_covered = true;
break;
}
}
// Throw error if controller not covered
if (!$controller_covered) {
// Bundle doesn't include the controller
BundleErrors::controller_not_covered(
$current_controller,
$current_action,
$controller_path,
$controller_dir,
$bundle_class,
$include_paths
);
}
}
} catch (RuntimeException $e) {
// If we can't find the controller in manifest, skip validation
// (might be a Laravel controller or other edge case)
if (!str_contains($e->getMessage(), 'not found in manifest')) {
throw $e;
}
}
}
}
/**
* Get bundle metadata for manifest
*
* @return array Bundle metadata
*/
public static function get_metadata(): array
{
return [
'type' => 'bundle',
'class' => static::class,
'definition' => static::define(),
];
}
}

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env node
/**
* CSS concatenation script with source map support
*
* Based on concat-js.js architecture
* Uses Mozilla source-map library for standard compatibility
*
* Usage: node concat-css.js <output-file> <input-file1> <input-file2> ...
*
* This script concatenates CSS files while preserving inline source maps.
*/
const fs = require('fs');
const path = require('path');
const { SourceMapConsumer, SourceMapGenerator } = require('source-map');
// Parse command line arguments
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: node concat-css.js <output-file> <input-file1> [input-file2] ...');
process.exit(1);
}
const outputFile = args[0];
const inputFiles = args.slice(1);
/**
* Extracts inline Base64 sourcemap from CSS content
* Returns { content: string without sourcemap, map: parsed object or null }
*/
function extractSourceMap(content, filename) {
// CSS sourcemap comment format
const regex = /\/\*#\s*sourceMappingURL=([^\s*]+)\s*\*\//m;
const match = content.match(regex);
if (!match || !match[1]) {
return { content, map: null };
}
const url = match[1];
// Handle inline Base64 data URLs
if (url.startsWith('data:')) {
const base64Match = url.match(/base64,(.*)$/);
if (base64Match) {
try {
const json = Buffer.from(base64Match[1], 'base64').toString('utf8');
const map = JSON.parse(json);
// Remove sourcemap comment from content
const cleanContent = content.replace(regex, '');
return { content: cleanContent, map };
} catch (e) {
console.warn(`Warning: Failed to parse sourcemap for ${filename}: ${e.message}`);
return { content, map: null };
}
}
}
// External sourcemap files not supported in concatenation
console.warn(`Warning: External sourcemap "${url}" in ${filename} will be ignored`);
return { content, map: null };
}
/**
* Main concatenation logic using Mozilla source-map library
*/
async function concatenateFiles() {
// Create combined sourcemap generator
const generator = new SourceMapGenerator({
file: path.basename(outputFile)
});
// Track source contents for embedding
const sourceContents = {};
// Output content parts
const outputParts = [];
// Add header comment
outputParts.push(`/* Concatenated CSS bundle: ${path.basename(outputFile)} */\n`);
outputParts.push(`/* Generated: ${new Date().toISOString()} */\n\n`);
let currentLine = 3; // Start after header comments
// Process each input file
for (const inputFile of inputFiles) {
if (!fs.existsSync(inputFile)) {
console.error(`Error: Input file not found: ${inputFile}`);
process.exit(1);
}
// Read file content
const content = fs.readFileSync(inputFile, 'utf-8');
// Generate relative path for better source map references
const relativePath = path.relative(process.cwd(), inputFile);
// Add file separator comment
const separatorComment = `/* === ${relativePath} === */\n`;
outputParts.push(separatorComment);
currentLine++;
// Extract sourcemap if present
const { content: cleanContent, map } = extractSourceMap(content, relativePath);
// Store source content for embedding
sourceContents[relativePath] = cleanContent;
if (map) {
// File has a sourcemap - merge it
const consumer = await new SourceMapConsumer(map);
// Store source contents from the existing sourcemap
if (map.sourcesContent && map.sources) {
map.sources.forEach((source, idx) => {
if (map.sourcesContent[idx]) {
sourceContents[source] = map.sourcesContent[idx];
}
});
}
// Map each line through the existing sourcemap
const lines = cleanContent.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// For each line, try to map it back to original source
const originalPos = consumer.originalPositionFor({
line: i + 1,
column: 0
});
if (originalPos.source) {
// We found an original mapping
generator.addMapping({
generated: {
line: currentLine,
column: 0
},
original: {
line: originalPos.line,
column: originalPos.column || 0
},
source: originalPos.source
});
}
outputParts.push(line + (i < lines.length - 1 ? '\n' : ''));
currentLine++;
}
consumer.destroy();
} else {
// No sourcemap - generate identity mappings
const lines = cleanContent.split('\n');
for (let i = 0; i < lines.length; i++) {
// Map each line to itself in the original file
generator.addMapping({
generated: {
line: currentLine,
column: 0
},
original: {
line: i + 1,
column: 0
},
source: relativePath
});
outputParts.push(lines[i] + (i < lines.length - 1 ? '\n' : ''));
currentLine++;
}
}
// Add extra newline between files
outputParts.push('\n');
currentLine++;
}
// Generate the final sourcemap
const mapJSON = generator.toJSON();
// Ensure sourceRoot is set properly
mapJSON.sourceRoot = '';
// Ensure all sources are relative paths
if (mapJSON.sources) {
mapJSON.sources = mapJSON.sources.map(source => {
if (!source) return source;
// If it's an absolute path, make it relative
if (path.isAbsolute(source)) {
return path.relative(process.cwd(), source);
}
return source;
});
}
// Add source contents to the sourcemap for inline viewing
if (mapJSON.sources) {
mapJSON.sourcesContent = mapJSON.sources.map(source => {
return sourceContents[source] || null;
});
}
// Convert sourcemap to Base64 and append as inline comment
const base64Map = Buffer.from(JSON.stringify(mapJSON)).toString('base64');
const finalContent = outputParts.join('') +
`\n/*# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map} */\n`;
// Write the output file
const outputDir = path.dirname(outputFile);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputFile, finalContent, 'utf-8');
// Report success
const fileSizeKb = (fs.statSync(outputFile).size / 1024).toFixed(2);
console.log(`✅ Concatenated ${inputFiles.length} CSS files to ${outputFile} (${fileSizeKb} KB)`);
// Report sourcemap details
console.log(` Sources in map: ${mapJSON.sources.length} files`);
const mapSizeKb = (Buffer.byteLength(base64Map, 'utf-8') / 1024).toFixed(2);
console.log(` Inline sourcemap: ${mapSizeKb} KB`);
}
// Run the concatenation
concatenateFiles().catch(err => {
console.error(`Error: ${err.message}`);
if (err.stack) {
console.error(err.stack);
}
process.exit(1);
});

View File

@@ -0,0 +1,260 @@
#!/usr/bin/env node
/**
* JavaScript concatenation script with source map support
*
* MIGRATED TO MOZILLA SOURCE-MAP LIBRARY (2025-09-23)
* As per JQHTML team's RSPADE_SOURCEMAP_MIGRATION_GUIDE.md
*
* Usage: node concat-js.js <output-file> <input-file1> <input-file2> ...
*
* This script concatenates JavaScript files while preserving inline source maps
* using Mozilla's source-map library for industry-standard compatibility.
*/
const fs = require('fs');
const path = require('path');
const { SourceMapConsumer, SourceMapGenerator, SourceNode } = require('source-map');
// Parse command line arguments
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: node concat-js.js <output-file> <input-file1> [input-file2] ...');
process.exit(1);
}
const outputFile = args[0];
const inputFiles = args.slice(1);
/**
* Extracts inline Base64 sourcemap from JavaScript content
* CRITICAL: Uses exact regex pattern from JQHTML team - DO NOT MODIFY
* Returns { content: string without sourcemap, map: parsed object or null }
*/
function extractSourceMap(content, filename) {
// EXACT regex pattern - do not modify
const regex = /(?:\/\/[@#][ \t]+sourceMappingURL=([^\s'"]+?)[ \t]*$)/m;
const match = content.match(regex);
if (!match || !match[1]) {
return { content, map: null };
}
const url = match[1];
// Handle inline Base64 data URLs
if (url.startsWith('data:')) {
const base64Match = url.match(/base64,(.*)$/);
if (base64Match) {
try {
const json = Buffer.from(base64Match[1], 'base64').toString('utf8');
const map = JSON.parse(json);
// Remove sourcemap comment from content
const cleanContent = content.replace(regex, '');
return { content: cleanContent, map };
} catch (e) {
console.warn(`Warning: Failed to parse sourcemap for ${filename}: ${e.message}`);
return { content, map: null };
}
}
}
// External sourcemap files not supported in concatenation
console.warn(`Warning: External sourcemap "${url}" in ${filename} will be ignored`);
return { content, map: null };
}
/**
* Main concatenation logic using Mozilla source-map library
*/
async function concatenateFiles() {
// Create root SourceNode for concatenation
const rootNode = new SourceNode(null, null, null);
// Track source contents for embedding in sourcemap
const sourceContents = {};
// Add header comment
rootNode.add(`/* Concatenated bundle: ${path.basename(outputFile)} */\n`);
rootNode.add(`/* Generated: ${new Date().toISOString()} */\n\n`);
// Process each input file
for (const inputFile of inputFiles) {
if (!fs.existsSync(inputFile)) {
console.error(`Error: Input file not found: ${inputFile}`);
process.exit(1);
}
// Read file content
const content = fs.readFileSync(inputFile, 'utf-8');
// Generate relative path for better source map references
const relativePath = path.relative(process.cwd(), inputFile);
// Add file separator comment
rootNode.add(`/* === ${relativePath} === */\n`);
// Extract sourcemap if present
const { content: cleanContent, map } = extractSourceMap(content, relativePath);
// Store source content for embedding in sourcemap
sourceContents[relativePath] = cleanContent;
// Check if this is a compiled JQHTML file
const isJqhtml = content.includes('/* Compiled from:') && content.includes('.jqhtml */');
if (map) {
// File has a sourcemap - use it
let consumer = await new SourceMapConsumer(map);
// Apply 2-line offset for JQHTML files
if (isJqhtml) {
// JQHTML templates need a 2-line offset because the template definition
// starts on line 3 of the source (after <Define:ComponentName>)
const offsetMap = JSON.parse(JSON.stringify(map));
// Update mappings to add 2-line offset
const generator = new SourceMapGenerator({
file: offsetMap.file,
sourceRoot: offsetMap.sourceRoot
});
// Re-apply all mappings with offset
consumer.eachMapping(mapping => {
if (mapping.source && mapping.originalLine) {
generator.addMapping({
generated: {
line: mapping.generatedLine,
column: mapping.generatedColumn
},
original: {
line: mapping.originalLine + 2, // Add 2-line offset
column: mapping.originalColumn
},
source: mapping.source,
name: mapping.name
});
}
});
// Clean up old consumer
consumer.destroy();
// Use new consumer with offset mappings
const offsetMapJson = generator.toJSON();
offsetMapJson.sources = map.sources;
offsetMapJson.sourcesContent = map.sourcesContent;
consumer = await new SourceMapConsumer(offsetMapJson);
}
// Store any additional sources from the existing sourcemap
if (map.sourcesContent && map.sources) {
map.sources.forEach((source, idx) => {
if (map.sourcesContent[idx]) {
sourceContents[source] = map.sourcesContent[idx];
}
});
}
// Create a SourceNode from the file content with its sourcemap
const node = SourceNode.fromStringWithSourceMap(
cleanContent + '\n', // Add newline separator between files
consumer
);
rootNode.add(node);
// Clean up consumer to prevent memory leaks
consumer.destroy();
} else {
// No sourcemap - generate identity mappings (each line maps to itself)
const lines = cleanContent.split('\n');
const fileNode = new SourceNode();
// Apply 2-line offset for JQHTML files without existing sourcemaps
const lineOffset = isJqhtml ? 2 : 0;
for (let i = 0; i < lines.length; i++) {
// Map each line to its original position (with offset for JQHTML)
fileNode.add(new SourceNode(
i + 1 + lineOffset, // line (1-indexed) + offset for JQHTML
0, // column
relativePath, // source filename
lines[i] + (i < lines.length - 1 ? '\n' : '') // preserve newlines except last
));
}
// Add final newline separator
fileNode.add('\n');
rootNode.add(fileNode);
}
// Add extra newline between files
rootNode.add('\n');
}
// Generate the concatenated result with merged sourcemap
const { code, map } = rootNode.toStringWithSourceMap({
file: path.basename(outputFile)
});
// Convert sourcemap to JSON for final processing
const mapJSON = map.toJSON();
// Ensure sourceRoot is set properly
mapJSON.sourceRoot = '';
// Ensure all sources are relative paths
if (mapJSON.sources) {
mapJSON.sources = mapJSON.sources.map(source => {
if (!source) return source;
// If it's an absolute path, make it relative
if (path.isAbsolute(source)) {
return path.relative(process.cwd(), source);
}
return source;
});
}
// Add source contents to the sourcemap for inline viewing
if (mapJSON.sources) {
mapJSON.sourcesContent = mapJSON.sources.map(source => {
// Try to find content for this source
return sourceContents[source] || null;
});
}
// Convert sourcemap to Base64 and append as inline comment
// CRITICAL: Include charset=utf-8 as specified by JQHTML team
const base64Map = Buffer.from(JSON.stringify(mapJSON)).toString('base64');
const finalCode = code +
`\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}\n`;
// Write the output file
const outputDir = path.dirname(outputFile);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputFile, finalCode, 'utf-8');
// Report success
const fileSizeKb = (fs.statSync(outputFile).size / 1024).toFixed(2);
console.log(`✅ Concatenated ${inputFiles.length} files to ${outputFile} (${fileSizeKb} KB)`);
// Report sourcemap details
console.log(` Sources in map: ${mapJSON.sources.join(', ')}`);
const mapSizeKb = (Buffer.byteLength(base64Map, 'utf-8') / 1024).toFixed(2);
console.log(` Inline sourcemap: ${mapSizeKb} KB`);
}
// Run the concatenation
concatenateFiles().catch(err => {
console.error(`Error: ${err.message}`);
if (err.stack) {
console.error(err.stack);
}
process.exit(1);
});

77
app/RSpade/Core/CLAUDE.md Executable file
View File

@@ -0,0 +1,77 @@
# Core Framework Systems
## Main_Abstract Middleware System
The framework provides application-wide middleware hooks via `Main_Abstract`:
### Implementation
1. **Create `/rsx/main.php`** extending `Main_Abstract` with three methods:
- `init()` - Called once during bootstrap
- `pre_dispatch(Request $request, array $params)` - Called before any route dispatch
- `unhandled_route(Request $request, array $params)` - Called when no route matches
2. **Pre-dispatch flow**:
- Main::pre_dispatch() called first
- Controller::pre_dispatch() called second
- If either returns non-null, dispatch halts with that response
3. **Controller/API Method Signatures**:
- All controller methods: `function method_name(Request $request, array $params = [])`
- All API methods: `function method_name(Request $request, array $params = [])`
- `$params` contains combined GET values and URL-extracted parameters
## Core JavaScript Classes
These classes are ALWAYS available - never check for their existence:
- `Rsx_Manifest` - Manifest management
- `Rsx_Cache` - Client-side caching
- `Rsx` - Core framework utilities
- All classes in `/app/RSpade/Core/Js/`
Use them directly without defensive coding:
```javascript
// ✅ GOOD
Rsx_Manifest.define(...)
// ❌ BAD
if (typeof Rsx_Manifest !== 'undefined') {
Rsx_Manifest.define(...)
}
```
## Dispatcher System
Maps HTTP requests to RSX controllers based on manifest data.
## Autoloader System
Provides path-agnostic class loading - classes are found by name, not path.
## Manifest System
Indexes all files in `/rsx/` for automatic discovery and loading.
## JQHTML Named Slots (v2.2.112+)
Child template syntax changed from `<#slotname />` tags to `content('slotname')` function:
- Old: `<#header />` (deprecated)
- New: `<%= content('header') %>` (v2.2.112+)
- Parent syntax unchanged: `<#header>content</#header>`
## JQHTML Slot-Based Template Inheritance (v2.2.108+)
When component template contains ONLY slots (no HTML), it automatically inherits parent class template structure:
- Enables abstract base components with customizable slots
- Child templates define slot-only files to extend parent templates
- Parent templates call slots: `<%= content('slotname', data) %>` (data passing supported)
- JavaScript class must extend parent: `class Child extends Parent`
- Slot names cannot be JavaScript reserved words (enforced by parser)
## JQHTML Define Tag Configuration
`<Define>` tag supports three attribute types:
- `extends="Parent"` - Explicit template inheritance
- `$property=value` - Set default this.args values (unquoted=JS expression, quoted=string)
- Regular HTML attributes (class, id, tag, data-*)
- Enables template-only components without JavaScript classes

View File

@@ -0,0 +1,424 @@
<?php
namespace App\RSpade\Core\Cache;
use Exception;
use Redis;
use App\RSpade\Core\Locks\RsxLocks;
use App\RSpade\Core\Manifest\Manifest;
// Ensure helpers are loaded since we may run early in bootstrap
require_once __DIR__ . '/../../helpers.php';
/**
* Redis-based caching system with LRU eviction
*
* Uses Redis database 0 with LRU eviction policy for general caching.
* Automatically prefixes all keys with the current manifest build key
* to ensure cache invalidation when code changes.
*/
class RsxCache
{
// Redis configuration
private static ?Redis $_redis = null;
private static int $cache_db = 0; // Database 0 for cache (with LRU eviction)
private static bool $initialized = false;
// Request-scoped cache (static property storage)
private static array $_once_cache = [];
// Default expiration times
public const NO_EXPIRATION = 0;
public const HOUR = 3600;
public const DAY = 86400;
public const WEEK = 604800;
/**
* Initialize the cache system
* Must be called after manifest is loaded
*/
public static function _init()
{
if (self::$_redis) {
return self::$_redis;
}
// Skip Redis in IDE context if extension not available
if (self::_redis_bypass()) {
return null;
}
self::$_redis = new Redis();
// Connect to Redis (will be configured via environment)
$host = env('REDIS_HOST', '127.0.0.1');
$port = env('REDIS_PORT', 6379);
$socket = env('REDIS_SOCKET', null);
if ($socket && file_exists($socket)) {
$connected = self::$_redis->connect($socket);
} else {
$connected = self::$_redis->connect($host, $port, 2.0);
}
if (!$connected) {
shouldnt_happen('Failed to connect to Redis for caching');
}
// Select the cache database (with LRU eviction)
self::$_redis->select(self::$cache_db);
return self::$_redis;
}
/**
* Check if we can skip reddis due to special circumstance
*/
private static function _redis_bypass()
{
if (is_ide() && !class_exists('\Redis') && env('APP_ENV') != 'production') {
return true;
}
return false;
}
/**
* Get a value from cache
*
* @param string $key Cache key
* @param mixed $default Default value if key not found
* @return mixed Cached value or default
*/
public static function get(string $key, $default = null)
{
return self::get_persistent(self::_transform_key_build($key), $default);
}
/**
* Same as ::get but survives changes in the development environment
*
* The developer must call this function during manifest build phase, because
* the build key to determine if the build has been updated is not available
* until the manifest resccan is complete. Calling get() during manifest rescan
* will throw an exception.
*
* @param string $key Cache key
* @param mixed $default Default value if key not found
* @return mixed Cached value or default
*/
public static function get_persistent(string $key, $default = null)
{
self::_init();
if (self::_redis_bypass()) {
return null;
}
$full_key = self::_make_key_persistent($key);
$value = self::$_redis->get($full_key);
if ($value === false) {
return $default;
}
// Attempt to unserialize
try {
return unserialize($value);
} catch (Exception $e) {
return $default;
}
}
/**
* Set a value in cache
*
* @param string $key Cache key
* @param mixed $value Value to cache
* @param int $expiration Expiration time in seconds (0 = never expire)
* @return bool Success
*/
public static function set(string $key, $value, int $expiration = self::NO_EXPIRATION): bool
{
return self::set_persistent(self::_transform_key_build($key), $value, $expiration);
}
/**
* Set a ::set but survives a manifest rescan
* See ::get_persistent
*
* @param string $key Cache key
* @param mixed $value Value to cache
* @param int $expiration Expiration time in seconds (0 = never expire)
* @return bool Success
*/
public static function set_persistent(string $key, $value, int $expiration = self::NO_EXPIRATION): bool
{
self::_init();
if (self::_redis_bypass()) {
return null;
}
$full_key = self::_make_key_persistent($key);
$value = serialize($value);
if ($expiration > 0) {
return self::$_redis->setex($full_key, $expiration, $value);
}
return self::$_redis->set($full_key, $value);
}
/**
* Delete a key from cache
*
* @param string $key Cache key
* @return bool Success
*/
public static function delete(string $key): bool
{
self::_init();
if (self::_redis_bypass()) {
return null;
}
$full_key = self::_make_key_persistent(self::_transform_key_build($key));
return self::$_redis->del($full_key) > 0;
}
/**
* Check if a key exists in cache
*
* @param string $key Cache key
* @return bool
*/
public static function exists(string $key): bool
{
self::_init();
if (self::_redis_bypass()) {
return null;
}
$full_key = self::_make_key_persistent(self::_transform_key_build($key));
return self::$_redis->exists($full_key) > 0;
}
/**
* Clear the entire cache
* Only clears keys with the current build key prefix
*/
public static function clear(): void
{
self::_init();
if (self::_redis_bypass()) {
return;
}
self::$_redis->flushDb();
}
/**
* Increment a numeric value
*
* @param string $key Cache key
* @param int $amount Amount to increment by
* @return int New value
*/
public static function increment(string $key, int $amount = 1): int
{
self::_init();
if (self::_redis_bypass()) {
return 0;
}
$full_key = self::_make_key_persistent(self::_transform_key_build($key));
if ($amount === 1) {
return self::$_redis->incr($full_key);
}
return self::$_redis->incrBy($full_key, $amount);
}
/**
* Decrement a numeric value
*
* @param string $key Cache key
* @param int $amount Amount to decrement by
* @return int New value
*/
public static function decrement(string $key, int $amount = 1): int
{
self::_init();
if (self::_redis_bypass()) {
return 0;
}
$full_key = self::_make_key_persistent(self::_transform_key_build($key));
if ($amount === 1) {
return self::$_redis->decr($full_key);
}
returnself::$_redis->decrBy($full_key, $amount);
}
/**
* Get cache statistics
*
* @return array Cache statistics
*/
public static function stats(): array
{
self::_init();
if (self::_redis_bypass()) {
return 0;
}
$_redis = self::$_redis;
// Return empty stats if Redis not available in IDE
if ($_redis === null) {
return [
'total_keys' => 0,
'used_memory' => 'N/A (IDE context)',
'used_memory_peak' => 'N/A (IDE context)',
'maxmemory' => '0',
'maxmemory_policy' => 'noeviction',
];
}
// Get info from Redis
$info = $_redis->info('memory');
$db_info = $_redis->info('keyspace');
// Parse keyspace info for current database
$db_key = 'db' . self::$cache_db;
$key_count = 0;
if (isset($db_info[$db_key])) {
$match_result = preg_match('/keys=(\d+)/', $db_info[$db_key], $matches);
if ($match_result) {
$key_count = (int)$matches[1];
}
}
// Count keys with our build prefix
$pattern = self::_make_key('*');
return [
'total_keys' => $key_count,
'used_memory' => $info['used_memory_human'] ?? 'unknown',
'used_memory_peak' => $info['used_memory_peak_human'] ?? 'unknown',
'maxmemory' => $_redis->config('GET', 'maxmemory')['maxmemory'] ?? '0',
'maxmemory_policy' => $_redis->config('GET', 'maxmemory-policy')['maxmemory-policy'] ?? 'noeviction',
];
}
// Document me
private static function _transform_key_build(string $key): string
{
return Manifest::get_build_key() . '_' . $key;
}
/**
* Create a full cache key with build prefix
*
* @param string $key User-provided key
* @return string Full cache key
*/
private static function _make_key_persistent(string $key): string
{
return 'cache:' . sha1($key);
}
/**
* Request-scoped cache - stores value in static property for request duration
*
* Simplest caching - no Redis, no locks, just memory. Perfect for expensive
* calculations that might be called multiple times in a single request.
*
* @param string $key Cache key (request-scoped)
* @param callable $callback Callback to generate value if not cached
* @return mixed Cached or generated value
*/
public static function once(string $key, callable $callback)
{
if (array_key_exists($key, self::$_once_cache)) {
return self::$_once_cache[$key];
}
$value = $callback();
self::$_once_cache[$key] = $value;
return $value;
}
/**
* Build-scoped cache with advisory locking
*
* Caches value in Redis with build key prefix. Uses advisory write lock
* during cache building to prevent stampede (multiple processes building
* same cache simultaneously). Cache survives until manifest rebuild.
*
* @param string $key Cache key (build-scoped)
* @param callable $callback Callback to generate value if not cached
* @param int|null $seconds Expiration in seconds (null = never expire)
* @return mixed Cached or generated value
*/
public static function remember(string $key, callable $callback, ?int $seconds = null)
{
self::_init();
// Check cache first (fast path - no lock needed)
$value = self::get($key);
if ($value !== null) {
return $value;
}
// Cache miss - acquire write lock to build cache
// This prevents multiple processes from building the same cache
$lock_key = 'cache_build:' . $key;
$lock_token = RsxLocks::get_lock(
RsxLocks::SERVER_LOCK,
$lock_key,
RsxLocks::WRITE_LOCK,
30 // 30 second timeout for cache building
);
try {
// Check cache again after acquiring lock
// Another process may have built it while we were waiting
$value = self::get($key);
if ($value !== null) {
return $value;
}
// Build the cache
$value = $callback();
// Store in cache
$expiration = $seconds ?? self::NO_EXPIRATION;
self::set($key, $value, $expiration);
return $value;
} finally {
RsxLocks::release_lock($lock_token);
}
}
}

View File

@@ -0,0 +1,327 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\CodeTemplates;
use App\RSpade\Core\Manifest\Manifest;
/**
* Processes stub templates with placeholder replacements
*
* Uses Laravel's {{ placeholder }} syntax for template substitution
*/
class StubProcessor
{
/**
* Process a stub file with given replacements
*
* @param string $stub_name Name of the stub file (without .stub extension)
* @param array $replacements Associative array of placeholder => value
* @return string Processed content
*/
public static function process(string $stub_name, array $replacements): string
{
$stub_path = __DIR__ . "/stubs/{$stub_name}.stub";
if (!file_exists($stub_path)) {
throw new \RuntimeException("Stub file not found: {$stub_path}");
}
$content = file_get_contents($stub_path);
// Replace each placeholder
foreach ($replacements as $key => $value) {
$placeholder = "{{ {$key} }}";
$content = str_replace($placeholder, $value, $content);
}
// Check for any unreplaced placeholders
if (preg_match('/\{\{\s*\w+\s*\}\}/', $content, $matches)) {
throw new \RuntimeException("Unreplaced placeholder found: {$matches[0]}");
}
return $content;
}
/**
* Convert module/feature names to class name format
* Example: "user_profile" becomes "User_Profile"
*
* @param string $name
* @return string
*/
public static function to_class_name(string $name): string
{
$parts = explode('_', $name);
return implode('_', array_map('ucfirst', $parts));
}
/**
* Convert module/feature names to human-readable title
* Example: "user_profile" becomes "User Profile"
*
* @param string $name
* @return string
*/
public static function to_title(string $name): string
{
$parts = explode('_', $name);
return implode(' ', array_map('ucfirst', $parts));
}
/**
* Check if a directory is a submodule
* A submodule has a layout file with @rsx_extends (not a bundle render)
*
* @param string $dir_path
* @return bool
*/
public static function is_submodule(string $dir_path): bool
{
if (!is_dir($dir_path)) {
return false;
}
// Look for layout files
$layout_files = glob($dir_path . '/*_layout.blade.php');
if (empty($layout_files)) {
return false;
}
// Check if the layout uses @rsx_extends
foreach ($layout_files as $layout_file) {
$content = file_get_contents($layout_file);
if (strpos($content, '@rsx_extends') !== false) {
return true;
}
}
return false;
}
/**
* Get the parent layout class for a submodule
*
* @param string $module_path
* @return string
*/
public static function get_parent_layout(string $module_path): string
{
// Look for the module's layout file
$layout_files = glob($module_path . '/*_layout.blade.php');
if (!empty($layout_files)) {
$content = file_get_contents($layout_files[0]);
// Extract the @rsx_id value
if (preg_match("/@rsx_id\(['\"]([^'\"]+)['\"]\)/", $content, $matches)) {
return $matches[1];
}
}
// Return default layout name based on module
$module_name = basename($module_path);
return self::to_class_name($module_name) . '_Layout';
}
/**
* Resolve parent route URL from existing controllers
* Tries to find actual route URL from parent controllers to inherit URL structure
*
* @param string $module_name
* @param string|null $submodule_name
* @param string|null $feature_name
* @return string|null Resolved URL or null if not found
*/
protected static function __resolve_parent_route_url(
string $module_name,
?string $submodule_name = null,
?string $feature_name = null
): ?string {
// Try to find parent controller and extract its route
$controller_attempts = [];
if ($submodule_name && $feature_name) {
// For subfeature: try Module_Submodule_Feature_Controller
$controller_attempts[] = self::to_class_name("{$module_name}_{$submodule_name}_{$feature_name}") . '_Controller';
} elseif ($feature_name) {
// For subfeature (no submodule): try Module_Feature_Controller
$controller_attempts[] = self::to_class_name("{$module_name}_{$feature_name}") . '_Controller';
} elseif ($submodule_name) {
// For submodule feature: try Module_Submodule_Controller with index action
$controller_attempts[] = self::to_class_name("{$module_name}_{$submodule_name}") . '_Index_Controller';
} else {
// For module feature: try Module_Index_Controller
$controller_attempts[] = self::to_class_name($module_name) . '_Index_Controller';
}
// Try each controller class
foreach ($controller_attempts as $controller_class) {
try {
// Find controller file in manifest
$controller_path = Manifest::php_find_class($controller_class);
if (!$controller_path) {
continue;
}
// Parse controller file for #[Route] attribute
$absolute_path = base_path($controller_path);
if (!file_exists($absolute_path)) {
continue;
}
$content = file_get_contents($absolute_path);
// Look for #[Route('...')] attribute on index method
// Match: #[Route('/some/path')] or #[Route('/some/path', ...)]
if (preg_match('/#\[Route\([\'"]([^\'"]+)[\'"]/m', $content, $matches)) {
return $matches[1]; // Return the route path
}
} catch (\Exception $e) {
// Controller not found or error parsing - continue to next attempt
continue;
}
}
return null;
}
/**
* Generate standard replacements for a feature
*
* @param string $module_name
* @param string|null $submodule_name
* @param string $feature_name
* @param string|null $subfeature_name
* @return array
*/
public static function generate_replacements(
string $module_name,
?string $submodule_name = null,
string $feature_name = 'index',
?string $subfeature_name = null
): array {
$replacements = [];
// Basic naming
$replacements['module_name'] = $module_name;
$replacements['module_class'] = self::to_class_name($module_name);
$replacements['module_title'] = self::to_title($module_name);
$replacements['module_path'] = "rsx/app/{$module_name}";
// Determine the full path and naming based on structure
if ($submodule_name) {
$replacements['submodule_name'] = $submodule_name;
$replacements['submodule_class'] = self::to_class_name($submodule_name);
$replacements['submodule_title'] = self::to_title($submodule_name);
$replacements['submodule_css_class'] = self::to_class_name("{$module_name}_{$submodule_name}");
if ($subfeature_name) {
// Module > Submodule > Feature > Subfeature
$controller_prefix = self::to_class_name("{$module_name}_{$submodule_name}_{$feature_name}_{$subfeature_name}");
$file_prefix = "{$module_name}_{$submodule_name}_{$feature_name}_{$subfeature_name}";
// Try to resolve parent route, fallback* to generated path
$parent_url = self::__resolve_parent_route_url($module_name, $submodule_name, $feature_name);
$route_path = $parent_url ? "{$parent_url}/{$subfeature_name}" : "/{$module_name}/{$submodule_name}/{$feature_name}/{$subfeature_name}";
// Subfeature uses parent feature's namespace (not subfeature directory)
$layout_class = self::to_class_name("{$module_name}_{$submodule_name}") . '_Layout';
$bundle_class = self::to_class_name($module_name) . '_Bundle';
$namespace = "Rsx\\App\\" . self::to_class_name($module_name) . "\\" . self::to_class_name($submodule_name) . "\\" . self::to_class_name($feature_name);
$feature_description = "Handle {$subfeature_name} subfeature of {$feature_name} in {$submodule_name}";
} else if ($feature_name !== 'index') {
// Module > Submodule > Feature
$controller_prefix = self::to_class_name("{$module_name}_{$submodule_name}_{$feature_name}");
$file_prefix = "{$module_name}_{$submodule_name}_{$feature_name}";
$route_path = "/{$module_name}/{$submodule_name}/{$feature_name}";
$feature_description = "Handle {$feature_name} feature in {$submodule_name}";
} else {
// Module > Submodule > Index
$controller_prefix = self::to_class_name("{$module_name}_{$submodule_name}") . '_Index';
$file_prefix = "{$module_name}_{$submodule_name}_index";
$route_path = "/{$module_name}/{$submodule_name}";
$feature_description = "Handle index page for {$submodule_name}";
}
$layout_class = self::to_class_name("{$module_name}_{$submodule_name}") . '_Layout';
$bundle_class = self::to_class_name($module_name) . '_Bundle';
$namespace = "Rsx\\App\\" . self::to_class_name($module_name) . "\\" . self::to_class_name($submodule_name);
} else if ($subfeature_name) {
// Module > Feature > Subfeature (no submodule)
$controller_prefix = self::to_class_name("{$module_name}_{$feature_name}_{$subfeature_name}");
$file_prefix = "{$module_name}_{$feature_name}_{$subfeature_name}";
// Try to resolve parent route, fallback* to generated path
$parent_url = self::__resolve_parent_route_url($module_name, null, $feature_name);
$route_path = $parent_url ? "{$parent_url}/{$subfeature_name}" : "/{$module_name}/{$feature_name}/{$subfeature_name}";
$layout_class = self::to_class_name($module_name) . '_Layout';
$bundle_class = self::to_class_name($module_name) . '_Bundle';
$namespace = "Rsx\\App\\" . self::to_class_name($module_name) . "\\" . self::to_class_name($feature_name);
$feature_description = "Handle {$subfeature_name} subfeature of {$feature_name}";
} else if ($feature_name !== 'index') {
// Module > Feature
$controller_prefix = self::to_class_name("{$module_name}_{$feature_name}");
$file_prefix = "{$module_name}_{$feature_name}";
// Try to resolve module's index route, fallback* to generated path
$parent_url = self::__resolve_parent_route_url($module_name, null, null);
$route_path = $parent_url ? "{$parent_url}/{$feature_name}" : "/{$module_name}/{$feature_name}";
$layout_class = self::to_class_name($module_name) . '_Layout';
$bundle_class = self::to_class_name($module_name) . '_Bundle';
$namespace = "Rsx\\App\\" . self::to_class_name($module_name) . "\\" . self::to_class_name($feature_name);
$feature_description = "Handle {$feature_name} feature";
} else {
// Module > Index
$controller_prefix = self::to_class_name($module_name) . '_Index';
$file_prefix = "{$module_name}_index";
// Special case: 'frontend' module goes to root /
$route_path = $module_name === 'frontend' ? '/' : "/{$module_name}";
$layout_class = self::to_class_name($module_name) . '_Layout';
$bundle_class = self::to_class_name($module_name) . '_Bundle';
$namespace = "Rsx\\App\\" . self::to_class_name($module_name);
$feature_description = "Handle index page for {$module_name}";
}
// Common replacements
$replacements['controller_prefix'] = $controller_prefix;
$replacements['controller_class'] = $controller_prefix . '_Controller';
$replacements['view_class'] = $controller_prefix;
$replacements['js_class'] = $controller_prefix;
$replacements['file_prefix'] = $file_prefix;
$replacements['route_path'] = $route_path;
$replacements['layout_class'] = $layout_class;
$replacements['bundle_class'] = $bundle_class;
$replacements['namespace'] = $namespace;
$replacements['feature_description'] = $feature_description;
// View-specific
$replacements['page_title'] = self::to_title($subfeature_name ?? $feature_name);
$replacements['view_path'] = $controller_prefix;
// Determine extends layout
if ($submodule_name) {
$replacements['extends_layout'] = self::to_class_name("{$module_name}_{$submodule_name}") . '_Layout';
$replacements['parent_layout_class'] = self::to_class_name($module_name) . '_Layout';
} else {
$replacements['extends_layout'] = $layout_class;
}
// Section declarations for views
if ($submodule_name) {
$replacements['section_declaration'] = "@section('submodule_content')";
$replacements['section_end'] = "@endsection";
} else {
$replacements['section_declaration'] = "@section('content')";
$replacements['section_end'] = "@endsection";
}
return $replacements;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace {{ namespace }};
use App\RSpade\Core\Bundle\Rsx_Bundle_Abstract;
class {{ bundle_class }} extends Rsx_Bundle_Abstract
{
/**
* Define the bundle configuration
*
* @return array Bundle configuration
*/
public static function define(): array
{
return [
'include' => [
'jquery', // jQuery library (required module)
'lodash', // Lodash utilities (required module)
'bootstrap5_src', // Bootstrap 5 SCSS source bundle
'rsx/theme/variables.scss', // Global SCSS variables
'{{ module_path }}', // Module directory
'rsx/lib', // Shared libraries
'rsx/models', // Models for JS stub generation
'rsx/theme', // Theme assets
],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace {{ namespace }};
use Illuminate\Http\Request;
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
use Route;
class {{ controller_class }} extends Rsx_Controller_Abstract
{
// Configure access control: php artisan rsx:man controller
// Use Permission::anybody() or Permission::authenticated()
#[Auth('Permission::anybody()')]
public static function pre_dispatch(Request $request, array $params = [])
{
return null;
}
/**
* {{ feature_description }}
*
* @param Request $request
* @param array $params
* @return mixed
*/
#[Route('{{ route_path }}')]
public static function index(Request $request, array $params = [])
{
$data = [
// Add your data here
];
return rsx_view('{{ view_path }}', $data);
}
}

View File

@@ -0,0 +1,26 @@
class {{ js_class }} {
static init() {
if (!$(".{{ view_class }}").exists()) return;
// Initialize your component here
console.log('{{ js_class }} initialized');
// Example: Handle button clicks
$('.btn-action').on('click', function() {
// Handle action
});
// Example: Initialize tooltips
$('[data-bs-toggle="tooltip"]').tooltip();
}
static on_app_ready() {
{{ js_class }}.init();
}
// static on_jqhtml_ready() {
// // Called after all JQHTML components have loaded and rendered
// // Use this if you need to interact with JQHTML components
// // Otherwise, use on_app_ready() for most initialization
// }
}

View File

@@ -0,0 +1,104 @@
@rsx_id('{{ layout_class }}')
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>@yield('title', '{{ module_title }}') - {{ config('rspade.name', 'RSX') }}</title>
{{-- Bootstrap Icons --}}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
{{-- Bundle includes --}}
{!! {{ bundle_class }}::render() !!}
</head>
<body class="{{ rsx_body_class() }}">
{{-- Header Navigation --}}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top shadow-sm">
<div class="container-fluid">
{{-- Brand --}}
<a class="navbar-brand fw-bold" href="{{ Rsx::Route('{{ module_class }}_Index_Controller')->url() }}">
{{ module_title }}
</a>
{{-- Mobile Toggle --}}
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarMain">
<span class="navbar-toggler-icon"></span>
</button>
{{-- Main Navigation --}}
<div class="collapse navbar-collapse" id="navbarMain">
{{-- Left Side Navigation --}}
<ul class="navbar-nav me-auto">
{{--
Example navigation items with active state detection:
The active class is added using request()->is() to match URL patterns
--}}
<li class="nav-item">
<a class="nav-link {{ request()->is('{{ module_name }}') || request()->is('{{ module_name }}/index') ? 'active' : '' }}"
href="{{ Rsx::Route('{{ module_class }}_Index_Controller')->url() }}">
<i class="bi bi-house-door"></i> Home
</a>
</li>
{{-- Add more navigation items here following the same pattern --}}
{{--
<li class="nav-item">
<a class="nav-link {{ request()->is('{{ module_name }}/feature*') ? 'active' : '' }}"
href="{{ Rsx::Route('{{ module_class }}_Feature_Controller')->url() }}">
<i class="bi bi-star"></i> Feature
</a>
</li>
--}}
</ul>
{{-- Right Side Navigation --}}
<div class="d-flex align-items-center">
{{-- User Profile Dropdown --}}
@if(\App\RSpade\Core\Session\Session::is_logged_in())
<div class="dropdown">
<button type="button" class="btn btn-dark btn-sm dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i>
{{ explode('@', \App\RSpade\Core\Session\Session::get_user()->email)[0] }}
</button>
<ul class="dropdown-menu dropdown-menu-end shadow">
<li><a class="dropdown-item" href="#">
<i class="bi bi-person"></i> Profile
</a></li>
<li><a class="dropdown-item" href="#">
<i class="bi bi-gear"></i> Settings
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ Rsx::Route('Login_Index_Controller', '#logout')->url() }}">
<i class="bi bi-box-arrow-right"></i> Sign Out
</a></li>
</ul>
</div>
@else
<a class="btn btn-outline-light btn-sm" href="{{ Rsx::Route('Login_Index_Controller', '#show_login')->url() }}">
Login
</a>
@endif
</div>
</div>
</div>
</nav>
{{-- Main Content Area --}}
<main class="main-content">
{{-- Flash Messages --}}
@php
$flash_alerts = \App\RSpade\Core\Rsx::render_flash_alerts();
@endphp
@if($flash_alerts)
<div class="container-fluid mt-3">
{!! $flash_alerts !!}
</div>
@endif
@yield('content')
</main>
</body>
</html>

View File

@@ -0,0 +1,16 @@
.{{ view_class }} {
// Component-specific styles
.card {
// Card styles
}
.btn-action {
// Button styles
}
// Responsive adjustments
@media (max-width: 768px) {
// Mobile styles
}
}

View File

@@ -0,0 +1,69 @@
@rsx_id('{{ layout_class }}')
@rsx_extends('{{ parent_layout_class }}')
@section('title')
@yield('submodule_title', '{{ submodule_title }}')
@endsection
@section('content')
<div class="{{ submodule_css_class }}">
<div class="container-fluid">
<div class="row">
{{-- Sidebar --}}
<div class="col-md-3 col-lg-2 d-md-block bg-light sidebar">
<div class="position-sticky pt-3">
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted text-uppercase">
<span>{{ submodule_title }}</span>
</h6>
<ul class="nav flex-column mb-3">
{{--
Example navigation items with active state detection:
The active class is added using request()->is() to match URL patterns
--}}
<li class="nav-item">
<a class="nav-link {{ request()->is('{{ module_name }}/{{ submodule_name }}') || request()->is('{{ module_name }}/{{ submodule_name }}/index') ? 'active' : '' }}"
href="{{ Rsx::Route('{{ controller_prefix }}_Index_Controller')->url() }}">
<i class="bi bi-house"></i> Overview
</a>
</li>
{{-- Add more navigation items here following the same pattern --}}
{{--
<li class="nav-item">
<a class="nav-link {{ request()->is('{{ module_name }}/{{ submodule_name }}/feature*') ? 'active' : '' }}"
href="{{ Rsx::Route('{{ controller_prefix }}_Feature_Controller')->url() }}">
<i class="bi bi-star"></i> Feature
</a>
</li>
--}}
</ul>
@hasSection('sidebar_additional')
<hr class="mx-3">
@yield('sidebar_additional')
@endif
</div>
</div>
{{-- Main Content --}}
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
{{-- Page Header with Breadcrumbs --}}
<div class="row mb-4 mt-3">
<div class="col">
<h1 class="h3 mb-0">@yield('page_title', '{{ submodule_title }}')</h1>
<nav aria-label="breadcrumb" class="mt-2">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ Rsx::Route('{{ module_class }}_Index_Controller')->url() }}">{{ module_title }}</a></li>
<li class="breadcrumb-item">{{ submodule_title }}</li>
@yield('breadcrumb_items')
</ol>
</nav>
</div>
</div>
@yield('submodule_content')
</main>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,53 @@
.{{ submodule_css_class }} {
// Sidebar styles
.sidebar {
min-height: calc(100vh - 56px);
padding: 0;
.sidebar-heading {
font-size: 0.75rem;
text-transform: uppercase;
}
.nav-link {
color: #333;
padding: 0.5rem 1rem;
&.active {
color: #0d6efd;
background-color: rgba(13, 110, 253, 0.1);
border-left: 3px solid #0d6efd;
}
&:hover:not(.active) {
background-color: #f8f9fa;
}
i {
width: 20px;
text-align: center;
margin-right: 0.5rem;
}
}
}
// Main content area
main {
padding-bottom: 2rem;
}
// Card adjustments
.card {
border: 0;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
// Responsive adjustments
@media (max-width: 767px) {
.sidebar {
position: static;
min-height: auto;
padding: 1rem 0;
}
}
}

View File

@@ -0,0 +1,22 @@
@rsx_id('{{ view_class }}')
@rsx_extends('{{ extends_layout }}')
@section('title', '{{ page_title }}')
{{ section_declaration }}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">{{ page_title }}</h5>
</div>
<div class="card-body">
<p>Welcome to {{ page_title }}!</p>
{{-- Add your content here --}}
</div>
</div>
</div>
</div>
</div>
{{ section_end }}

View File

@@ -0,0 +1,225 @@
<?php
namespace App\RSpade\Core\Controller;
use App\RSpade\Core\Bundle\BundleIntegration_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* Controller integration for RSX framework
*
* Handles generation of JavaScript stub files for controllers with Ajax_Endpoint methods,
* enabling clean Ajax.call() syntax from JavaScript.
*/
class Controller_BundleIntegration extends BundleIntegration_Abstract
{
/**
* Get the integration's unique identifier
*
* @return string Integration identifier
*/
public static function get_name(): string
{
return 'controller';
}
/**
* Get file extensions handled by this integration
*
* Controllers are PHP files, but we don't need to register
* extensions as the PHP files are already handled by the core.
*
* @return array Empty array as no special extensions needed
*/
public static function get_file_extensions(): array
{
return [];
}
/**
* Generate JavaScript stub files for controllers with Ajax_Endpoint methods
*
* Called during manifest building (Phase 5) to generate JavaScript
* stub files that provide IDE autocomplete and runtime functionality
* for PHP controllers with Ajax_Endpoint annotated methods.
*
* @param array &$manifest_data The complete manifest data (passed by reference)
* @return void
*/
public static function generate_manifest_stubs(array &$manifest_data): void
{
$stub_dir = storage_path('rsx-build/js-stubs');
// Create directory if it doesn't exist
if (!is_dir($stub_dir)) {
mkdir($stub_dir, 0755, true);
}
// Track generated stub files for cleanup
$generated_stubs = [];
// Process each file
foreach ($manifest_data['data']['files'] as $file_path => &$metadata) {
// Skip non-PHP files
if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') {
continue;
}
// Skip files without classes or public static methods
if (!isset($metadata['class']) || !isset($metadata['public_static_methods'])) {
continue;
}
// Check if this is a controller (extends Rsx_Controller_Abstract)
$class_name = $metadata['class'] ?? '';
if (!Manifest::php_is_subclass_of($class_name, 'Rsx_Controller_Abstract')) {
continue;
}
// Check if this controller has any Ajax_Endpoint methods
$api_methods = [];
foreach ($metadata['public_static_methods'] as $method_name => $method_info) {
if (!isset($method_info['attributes'])) {
continue;
}
// Check for Ajax_Endpoint attribute
$has_api_internal = false;
foreach ($method_info['attributes'] as $attr_name => $attr_data) {
if ($attr_name === 'Ajax_Endpoint' ||
basename(str_replace('\\', '/', $attr_name)) === 'Ajax_Endpoint') {
$has_api_internal = true;
break;
}
}
if ($has_api_internal && isset($method_info['static']) && $method_info['static']) {
$api_methods[$method_name] = [
'name' => $method_name,
];
}
}
// If no API methods, remove js_stub property if it exists and skip
if (empty($api_methods)) {
if (isset($metadata['js_stub'])) {
unset($metadata['js_stub']);
}
continue;
}
// Generate stub filename and paths
$controller_name = $metadata['class'];
$stub_filename = static::_sanitize_stub_filename($controller_name) . '.js';
$stub_relative_path = 'storage/rsx-build/js-stubs/' . $stub_filename;
$stub_full_path = base_path($stub_relative_path);
// Check if stub needs regeneration
$needs_regeneration = true;
if (file_exists($stub_full_path)) {
// Get mtime of source PHP file
$source_mtime = $metadata['mtime'] ?? 0;
$stub_mtime = filemtime($stub_full_path);
// Only regenerate if source is newer than stub
if ($stub_mtime >= $source_mtime) {
// Also check if the API methods signature has changed
// by comparing a hash of the methods
$api_methods_hash = md5(json_encode($api_methods));
$old_api_hash = $metadata['api_methods_hash'] ?? '';
if ($api_methods_hash === $old_api_hash) {
$needs_regeneration = false;
}
}
}
// Store the API methods hash for future comparisons
$metadata['api_methods_hash'] = md5(json_encode($api_methods));
if ($needs_regeneration) {
// Generate stub content
$stub_content = static::_generate_stub_content($controller_name, $api_methods);
// Write stub file
file_put_contents($stub_full_path, $stub_content);
}
$generated_stubs[] = $stub_filename;
// Add js_stub property to manifest data (relative path)
$metadata['js_stub'] = $stub_relative_path;
// Add the stub file itself to the manifest
// This is critical because storage/rsx-build/js-stubs is not in scan directories
$stat = stat($stub_full_path);
$manifest_data['data']['files'][$stub_relative_path] = [
'file' => $stub_relative_path,
'hash' => sha1_file($stub_full_path),
'mtime' => $stat['mtime'],
'size' => $stat['size'],
'extension' => 'js',
'class' => $controller_name,
'is_stub' => true, // Mark this as a generated stub
'source_controller' => $file_path, // Reference to the source controller
];
}
// Clean up orphaned stub files (both from disk and manifest)
$existing_stubs = glob($stub_dir . '/*.js');
foreach ($existing_stubs as $existing_stub) {
$filename = basename($existing_stub);
if (!in_array($filename, $generated_stubs)) {
// Remove from disk (check exists to avoid Windows errors)
if (file_exists($existing_stub)) {
unlink($existing_stub);
}
// Remove from manifest
$stub_relative_path = 'storage/rsx-build/js-stubs/' . $filename;
if (isset($manifest_data['data']['files'][$stub_relative_path])) {
unset($manifest_data['data']['files'][$stub_relative_path]);
}
}
}
}
/**
* Sanitize controller name for use as filename
*/
private static function _sanitize_stub_filename(string $controller_name): string
{
// Replace any non-alphanumeric characters (except underscores) with underscores
return preg_replace('/[^a-zA-Z0-9_]/', '_', $controller_name);
}
/**
* Generate JavaScript stub content for a controller
*/
private static function _generate_stub_content(string $controller_name, array $api_methods): string
{
$content = "/**\n";
$content .= " * Auto-generated JavaScript stub for {$controller_name}\n";
$content .= ' * Generated by RSX Manifest at ' . date('Y-m-d H:i:s') . "\n";
$content .= " * DO NOT EDIT - This file is automatically regenerated\n";
$content .= " */\n\n";
$content .= "class {$controller_name} {\n";
foreach ($api_methods as $method_name => $method_info) {
$content .= " /**\n";
$content .= " * Call {$controller_name}::{$method_name} via Ajax.call()\n";
$content .= " * @param {...*} args - Arguments to pass to the method\n";
$content .= " * @returns {Promise<*>}\n";
$content .= " */\n";
$content .= " static async {$method_name}(...args) {\n";
$content .= " return Ajax.call('{$controller_name}', '{$method_name}', args);\n";
$content .= " }\n\n";
}
$content .= "}\n";
return $content;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\RSpade\Core\Controller;
use App\RSpade\Core\Controller\Controller_BundleIntegration;
use App\RSpade\Core\Integration_Service_Provider_Abstract;
/**
* Controller_Service_Provider - Service provider for controller integration
*
* This provider registers the controller integration with the RSX framework.
* It handles generation of JavaScript stub files for controllers with
* Ajax_Endpoint methods.
*/
class Controller_Service_Provider extends Integration_Service_Provider_Abstract
{
/**
* Get the integration class for this provider
*
* @return string
*/
protected function get_integration_class(): string
{
return Controller_BundleIntegration::class;
}
}

View File

@@ -0,0 +1,143 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Controller;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\View;
/**
* Base controller class for all RSX controllers
*
* This extends Laravel's base controller and provides RSX-specific functionality.
* All RSX controllers should extend this class and use standard OOP patterns.
* Routes are defined using PHP 8 attributes on the controller and methods.
*/
#[Monoprogenic]
abstract class Rsx_Controller_Abstract extends BaseController
{
use AuthorizesRequests;
use ValidatesRequests;
/**
* Pre-dispatch hook called before any action
* Override in child classes to add pre-action logic
*
* @param Request $request The current request
* @param array $params Combined GET values and URL parameters
* @return mixed|null Return null to continue, or a response to halt dispatch
*/
public static function pre_dispatch(Request $request, array $params = [])
{
// Default implementation does nothing
// Override in child classes to add authentication, logging, etc.
return null;
}
/**
* Render a view with data
*
* @param string $view View name
* @param array $data Data to pass to view
* @return \Illuminate\View\View
*/
protected function view($view, $data = [])
{
return view($view, $data);
}
/**
* Return a redirect response
*
* @param string $url URL to redirect to
* @param int $status HTTP status code
* @return \Illuminate\Http\RedirectResponse
*/
protected function redirect($url, $status = 302)
{
return redirect($url, $status);
}
/**
* Return a raw response
*
* @param mixed $content Response content
* @param int $status HTTP status code
* @param array $headers HTTP headers
* @return Response
*/
protected function response($content, $status = 200, $headers = [])
{
return new Response($content, $status, $headers);
}
/**
* Return a JSON response (useful for AJAX requests from HTML pages)
*
* @param mixed $data Data to encode as JSON
* @param int $status HTTP status code
* @return \Illuminate\Http\JsonResponse
*/
protected function json($data, $status = 200)
{
return response()->json($data, $status);
}
/**
* Return a file download response
*
* @param string $path File path
* @param string|null $name Download filename
* @param array $headers Additional headers
* @return \Symfony\Component\HttpFoundation\BinaryFileResponse
*/
protected function download($path, $name = null, $headers = [])
{
return response()->download($path, $name, $headers);
}
/**
* Return an error response
*
* @param int $code HTTP error code
* @param string|null $message Error message
* @return Response
*/
protected function error($code = 404, $message = null)
{
abort($code, $message);
}
/**
* Get a parameter value with optional default
*
* @param array $params Parameters array
* @param string $key Parameter key
* @param mixed $default Default value if not found
* @return mixed
*/
protected static function __param($params, $key, $default = null)
{
return $params[$key] ?? $default;
}
/**
* Check if a parameter exists
*
* @param array $params Parameters array
* @param string $key Parameter key
* @return bool
*/
protected static function __has_param($params, $key)
{
return isset($params[$key]);
}
}

View File

@@ -0,0 +1,45 @@
# Database System Documentation
## Migration Policy
- **Forward-only migrations** - No rollbacks, no down() methods
- **Use migration snapshots** for development safety (migrate:begin/commit/rollback)
- **All migrations must be created via artisan** - Manual creation is blocked by whitelist
- Use `php artisan make:migration:safe` to create whitelisted migrations
- **Sequential execution only** - No --path option allowed
- **Fail on errors** - No fallback to old schemas, migrations must succeed or fail loudly
## Database Rules
- **No mass assignment**: All fields assigned explicitly
- **No eager loading**: ALL eager loading methods throw exceptions - use explicit queries for relationships
- *Note: Premature optimization is the root of all evil. When you have thousands of concurrent users in production and N+1 queries become a real bottleneck, you'll be motivated enough to remove these restrictions yourself.*
- **Forward-only migrations**: No down() methods
## Naming Conventions
- **Primary keys**: All tables must have `id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY` (exception: sessions table uses Laravel's VARCHAR id)
- **Foreign keys**: Always suffix with `_id` (e.g., `user_id`, `site_id`)
- **Boolean columns**: Always prefix with `is_` (e.g., `is_active`, `is_published`)
- **Timestamp columns**: Always suffix with `_at` (e.g., `created_at`, `updated_at`, `deleted_at`)
- **Integer types**: Use BIGINT for all integers, TINYINT(1) for booleans only
- **No unsigned integers**: All integers should be signed for easier migrations
- **UTF-8 encoding**: All text columns use UTF-8 (utf8mb4_unicode_ci collation)
## Schema Rules
- **BIGINT ID required**: ALL tables must have `id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY` - no exceptions (SIGNED for easier migrations)
- **Raw SQL migrations**: Use DB::statement() with raw MySQL, not Laravel schema builder
- **Restricted commands**: Several dangerous commands disabled (migrate:rollback, db:wipe, etc.)
- **Required columns enforced**: All tables have created_by, updated_by, etc.
- **UTF-8 encoding standard**: All text columns use UTF-8
## Migration Commands
```bash
php artisan make:migration:safe # Create whitelisted migration
php artisan migrate:begin # Start migration snapshot session
php artisan migrate # Run migrations with safety checks
php artisan migrate:commit # Commit migration changes
php artisan migrate:rollback # Rollback to snapshot (stays in session)
```

View File

@@ -0,0 +1,470 @@
<?php
namespace App\RSpade\Core\Database;
use App\RSpade\Core\Bundle\BundleIntegration_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* Database integration for RSX framework
*
* Handles generation of JavaScript stub files for ORM models,
* enabling Ajax ORM functionality and relationship methods.
*/
class Database_BundleIntegration extends BundleIntegration_Abstract
{
/**
* Get the integration's unique identifier
*
* @return string Integration identifier
*/
public static function get_name(): string
{
return 'database';
}
/**
* Get file extensions handled by this integration
*
* Models are PHP files, but we don't need to register
* extensions as the PHP files are already handled by the core.
*
* @return array Empty array as no special extensions needed
*/
public static function get_file_extensions(): array
{
return [];
}
/**
* Generate JavaScript stub files for ORM models
*
* These stubs enable IDE autocomplete and provide relationship methods
* for models that extend Rsx_Model_Abstract.
*
* TODO: This function needs cleanup
*
* @param array &$manifest_data The complete manifest data (passed by reference)
* @return void
*/
public static function generate_manifest_stubs(array &$manifest_data): void
{
// Debug: Track when and why this is called
// static $call_count = 0;
// $call_count++;
// console_debug("STUB_GEN", "Database stub generation call #{$call_count}");
// // Show backtrace to understand the call flow
// $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10);
// foreach ($backtrace as $idx => $frame) {
// if ($idx === 0) continue; // Skip this method itself
// $file = isset($frame['file']) ? basename($frame['file']) : 'unknown';
// $line = $frame['line'] ?? '?';
// $function = $frame['function'] ?? 'unknown';
// $class = isset($frame['class']) ? basename(str_replace('\\', '/', $frame['class'])) : '';
// console_debug("STUB_GEN", " [{$idx}] {$class}::{$function}() at {$file}:{$line}");
// }
$stub_dir = storage_path('rsx-build/js-model-stubs');
// Create directory if it doesn't exist
if (!is_dir($stub_dir)) {
mkdir($stub_dir, 0755, true);
}
// Track generated stub files for cleanup
$generated_stubs = [];
// Get all models from the manifest
$model_entries = Manifest::php_get_extending('Rsx_Model_Abstract');
console_debug('STUB_GEN', 'Found ' . count($model_entries) . ' models extending Rsx_Model_Abstract');
foreach ($model_entries as $model_entry) {
if (!isset($model_entry['fqcn'])) {
continue;
}
$fqcn = $model_entry['fqcn'];
$class_name = $model_entry['class'] ?? '';
// Skip if it extends Rsx_System_Model_Abstract
if (static::_php_is_subclass_of($fqcn, 'Rsx_System_Model_Abstract', $manifest_data)) {
console_debug('STUB_GEN', " Skipping {$class_name}: extends Rsx_System_Model_Abstract");
continue;
}
// Load the class and its hierarchy
Manifest::_load_class_hierarchy($fqcn, $manifest_data);
// Verify class loaded
if (!class_exists($fqcn)) {
shouldnt_happen("Failed to load model class {$fqcn} after _load_class_hierarchy");
}
// Note: Abstract classes already filtered by php_get_extending()
console_debug('STUB_GEN', " Processing {$class_name} for stub generation...");
// Get model metadata from manifest
$file_path = $model_entry['file'] ?? '';
$metadata = isset($manifest_data['data']['files'][$file_path]) ? $manifest_data['data']['files'][$file_path] : [];
// Generate stub filename and paths
$stub_filename = static::_sanitize_model_stub_filename($class_name) . '.js';
// Check if user has created their own JS class
$user_class_exists = static::_check_user_model_class_exists($class_name, $manifest_data);
// Use Base_ prefix if user class exists
$stub_class_name = $user_class_exists ? 'Base_' . $class_name : $class_name;
$stub_filename = static::_sanitize_model_stub_filename($stub_class_name) . '.js';
$stub_relative_path = 'storage/rsx-build/js-model-stubs/' . $stub_filename;
$stub_full_path = base_path($stub_relative_path);
// Check if stub needs regeneration
$needs_regeneration = true;
if (file_exists($stub_full_path)) {
// Get mtime of source PHP file
$source_mtime = $metadata['mtime'] ?? 0;
$stub_mtime = filemtime($stub_full_path);
// Only regenerate if source is newer than stub
if ($stub_mtime >= $source_mtime) {
// Also check if the model metadata has changed
// by comparing a hash of enums, relationships, and columns
$model_metadata = [];
// Get relationships
$model_metadata['rel'] = $fqcn::get_relationships();
// Get enums
if (property_exists($fqcn, 'enums')) {
$model_metadata['enums'] = $fqcn::$enums ?? [];
}
// Get columns from models metadata if available
if (isset($manifest_data['data']['models'][$class_name]['columns'])) {
$model_metadata['columns'] = $manifest_data['data']['models'][$class_name]['columns'];
}
$model_metadata_hash = md5(json_encode($model_metadata));
$old_metadata_hash = $metadata['model_metadata_hash'] ?? '';
if ($model_metadata_hash === $old_metadata_hash) {
$needs_regeneration = false;
}
// Store the hash for future comparisons
$manifest_data['data']['files'][$file_path]['model_metadata_hash'] = $model_metadata_hash;
}
}
if ($needs_regeneration) {
// Generate stub content
$stub_content = static::_generate_model_stub_content($fqcn, $class_name, $stub_class_name, $manifest_data);
// Write stub file
file_put_contents($stub_full_path, $stub_content);
// Store the metadata hash for future comparisons if not already done
if (!isset($manifest_data['data']['files'][$file_path]['model_metadata_hash'])) {
$model_metadata = [];
// Get relationships
$model_metadata['rel'] = $fqcn::get_relationships();
// Get enums
if (property_exists($fqcn, 'enums')) {
$model_metadata['enums'] = $fqcn::$enums ?? [];
}
// Get columns from models metadata if available
if (isset($manifest_data['data']['models'][$class_name]['columns'])) {
$model_metadata['columns'] = $manifest_data['data']['models'][$class_name]['columns'];
}
$manifest_data['data']['files'][$file_path]['model_metadata_hash'] = md5(json_encode($model_metadata));
}
}
$generated_stubs[] = $stub_filename;
// Add js_stub property to manifest data
$metadata['js_stub'] = $stub_relative_path;
// Write the updated metadata back to the manifest
$manifest_data['data']['files'][$file_path]['js_stub'] = $stub_relative_path;
// Debug: Verify the value was written
// console_debug('STUB_GEN', " Written js_stub for {$file_path}: {$stub_relative_path}");
// if (!isset($manifest_data['data']['files'][$file_path]['js_stub'])) {
// console_debug('STUB_GEN', ' ERROR: js_stub not set after writing!');
// }
// Add the stub file itself to the manifest
$stat = stat($stub_full_path);
$manifest_data['data']['files'][$stub_relative_path] = [
'file' => $stub_relative_path,
'hash' => sha1_file($stub_full_path),
'mtime' => $stat['mtime'],
'size' => $stat['size'],
'extension' => 'js',
'class' => $stub_class_name,
'is_model_stub' => true, // Mark this as a generated model stub
'source_model' => $file_path, // Reference to the source model
];
}
// Clean up orphaned stub files
$existing_stubs = glob($stub_dir . '/*.js');
foreach ($existing_stubs as $existing_stub) {
$filename = basename($existing_stub);
if (!in_array($filename, $generated_stubs)) {
// Remove from disk (check exists to avoid Windows errors)
if (file_exists($existing_stub)) {
unlink($existing_stub);
}
// Remove from manifest
$stub_relative_path = 'storage/rsx-build/js-model-stubs/' . $filename;
if (isset($manifest_data['data']['files'][$stub_relative_path])) {
unset($manifest_data['data']['files'][$stub_relative_path]);
}
}
}
}
/**
* Check if a class is a subclass of another class using manifest data
*
* IMPORTANT: This is NOT redundant with Manifest::php_is_subclass_of().
* This method operates on the raw manifest data array during the manifest
* build process, before the Manifest class has fully initialized its static
* data structures. It's called during Phase 5 (stub generation) when the
* manifest is being built, not when it's being queried.
*
* @param string $class_name The FQCN of the class to check
* @param string $parent_class The simple name of the parent class
* @param array $manifest_data The raw manifest data array being built
* @return bool True if class_name extends parent_class
*/
private static function _php_is_subclass_of(string $class_name, string $parent_class, array $manifest_data): bool
{
foreach ($manifest_data['data']['files'] as $file_path => $metadata) {
if (isset($metadata['fqcn']) && $metadata['fqcn'] === $class_name) {
$extends = $metadata['extends'] ?? '';
if ($extends === $parent_class) {
return true;
}
// Recursively check parent
if ($extends) {
return static::_php_is_subclass_of($extends, $parent_class, $manifest_data);
}
}
}
return false;
}
/**
* Check if user has created a JavaScript model class
*/
private static function _check_user_model_class_exists(string $model_name, array $manifest_data): bool
{
// Check if there's a JS file with this class name in the manifest
foreach ($manifest_data['data']['files'] as $file_path => $metadata) {
if (isset($metadata['extension']) && $metadata['extension'] === 'js') {
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
// Don't consider our own stubs
if (!isset($metadata['is_stub']) && !isset($metadata['is_model_stub'])) {
return true;
}
}
}
}
return false;
}
/**
* Sanitize model name for use as filename
*/
private static function _sanitize_model_stub_filename(string $model_name): string
{
// Replace underscores with hyphens and lowercase
// e.g., User_Model becomes user-model
return strtolower(str_replace('_', '-', $model_name));
}
/**
* Generate JavaScript stub content for a model
*/
private static function _generate_model_stub_content(string $fqcn, string $class_name, string $stub_class_name, array $manifest_data): string
{
// Ensure class is loaded before introspection
// (should already be loaded but double-check)
if (!class_exists($fqcn)) {
shouldnt_happen("Class {$fqcn} not loaded for stub generation");
}
// Get model instance to introspect
$model = new $fqcn();
// Get relationships
$relationships = $fqcn::get_relationships();
// Get enums
$enums = $fqcn::$enums ?? [];
// Get columns from models metadata if available
$columns = [];
if (isset($manifest_data['data']['models'][$class_name]['columns'])) {
$columns = $manifest_data['data']['models'][$class_name]['columns'];
}
// Start building the stub content
$content = "/**\n";
$content .= " * Auto-generated JavaScript stub for {$class_name}\n";
$content .= ' * Generated by RSX Manifest at ' . date('Y-m-d H:i:s') . "\n";
$content .= " * DO NOT EDIT - This file is automatically regenerated\n";
$content .= " */\n\n";
$content .= "class {$stub_class_name} extends Rsx_Js_Model {\n";
// Add static model name for API calls
$content .= " static get name() {\n";
$content .= " return '{$class_name}';\n";
$content .= " }\n\n";
// Generate enum constants and methods
foreach ($enums as $column => $enum_values) {
// Sort enum values by order property first, then by key
uksort($enum_values, function ($keyA, $keyB) use ($enum_values) {
$orderA = isset($enum_values[$keyA]['order']) ? $enum_values[$keyA]['order'] : 0;
$orderB = isset($enum_values[$keyB]['order']) ? $enum_values[$keyB]['order'] : 0;
// First compare by order
if ($orderA !== $orderB) {
return $orderA - $orderB;
}
// If order is same, compare by key (use spaceship operator for string comparison)
return $keyA <=> $keyB;
});
// Generate constants
foreach ($enum_values as $value => $props) {
if (!empty($props['constant'])) {
$value_json = json_encode($value);
$content .= " static {$props['constant']} = {$value_json};\n";
}
}
if (!empty($enum_values)) {
$content .= "\n";
}
// Generate enum value getter with Proxy for maintaining order
$content .= " static {$column}_enum_val() {\n";
$content .= " const data = {};\n";
$content .= " const order = [];\n";
// Generate the sorted entries
foreach ($enum_values as $value => $props) {
$value_json = json_encode($value);
$props_json = json_encode($props, JSON_UNESCAPED_SLASHES);
$content .= " data[{$value_json}] = {$props_json};\n";
$content .= " order.push({$value_json});\n";
}
$content .= " // Return Proxy that maintains sort order for enumeration\n";
$content .= " return new Proxy(data, {\n";
$content .= " ownKeys() {\n";
$content .= " return order.map(String);\n";
$content .= " },\n";
$content .= " getOwnPropertyDescriptor(target, prop) {\n";
$content .= " if (prop in target) {\n";
$content .= " return {\n";
$content .= " enumerable: true,\n";
$content .= " configurable: true,\n";
$content .= " value: target[prop]\n";
$content .= " };\n";
$content .= " }\n";
$content .= " }\n";
$content .= " });\n";
$content .= " }\n\n";
// Generate enum label list
$content .= " static {$column}_label_list() {\n";
$content .= " const values = {};\n";
foreach ($enum_values as $value => $props) {
if (isset($props['label'])) {
$value_json = json_encode($value);
$label = addslashes($props['label']);
$content .= " values[{$value_json}] = '{$label}';\n";
}
}
$content .= " return values;\n";
$content .= " }\n\n";
// Generate enum select method (for dropdowns)
// Consumes enum_val() data and extracts labels for selectable items
$content .= " static {$column}_enum_select() {\n";
$content .= " const fullData = this.{$column}_enum_val();\n";
$content .= " const data = {};\n";
$content .= " const order = [];\n";
$content .= " \n";
$content .= " // Extract labels from full data, respecting selectable flag\n";
$content .= " for (const key in fullData) {\n";
$content .= " const item = fullData[key];\n";
$content .= " if (item.selectable !== false && item.label) {\n";
$content .= " data[key] = item.label;\n";
$content .= " order.push(parseInt(key));\n";
$content .= " }\n";
$content .= " }\n";
$content .= " \n";
$content .= " // Return Proxy that maintains sort order for enumeration\n";
$content .= " return new Proxy(data, {\n";
$content .= " ownKeys() {\n";
$content .= " return order.map(String);\n";
$content .= " },\n";
$content .= " getOwnPropertyDescriptor(target, prop) {\n";
$content .= " if (prop in target) {\n";
$content .= " return {\n";
$content .= " enumerable: true,\n";
$content .= " configurable: true,\n";
$content .= " value: target[prop]\n";
$content .= " };\n";
$content .= " }\n";
$content .= " }\n";
$content .= " });\n";
$content .= " }\n\n";
}
// Generate relationship methods
foreach ($relationships as $relationship) {
$content .= " /**\n";
$content .= " * Fetch {$relationship} relationship\n";
$content .= " * @returns {Promise} Related model instance(s) or false\n";
$content .= " */\n";
$content .= " async {$relationship}() {\n";
$content .= " if (!this.id) {\n";
$content .= " shouldnt_happen('Cannot fetch relationship without id property');\n";
$content .= " }\n\n";
$content .= " const response = await $.ajax({\n";
$content .= " url: `/_fetch_rel/{$class_name}/\${this.id}/{$relationship}`,\n";
$content .= " method: 'POST',\n";
$content .= " dataType: 'json'\n";
$content .= " });\n\n";
$content .= " if (!response) return false;\n\n";
$content .= " // Convert response to model instance(s)\n";
$content .= " // Framework handles instantiation based on relationship type\n";
$content .= " return response;\n";
$content .= " }\n\n";
}
$content .= "}\n";
return $content;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\RSpade\Core\Database;
use App\RSpade\Core\Database\Database_BundleIntegration;
use App\RSpade\Core\Integration_Service_Provider_Abstract;
/**
* Database_Service_Provider - Service provider for database integration
*
* This provider registers the database integration with the RSX framework.
* It handles generation of JavaScript stub files for ORM models.
*/
class Database_Service_Provider extends Integration_Service_Provider_Abstract
{
/**
* Get the integration class for this provider
*
* @return string
*/
protected function get_integration_class(): string
{
return Database_BundleIntegration::class;
}
}

View File

@@ -0,0 +1,82 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Database;
/**
* Central location for migration path management
*
* RSpade supports migrations in two locations:
* 1. /database/migrations - Framework and system migrations
* 2. /rsx/resource/migrations - Application migrations (outside manifest scanning)
*
* The /resource/ directory is excluded from manifest scanning, making it suitable
* for migrations and other framework-related code that doesn't follow RSX conventions.
*/
class MigrationPaths
{
/**
* Get all migration directories in order they should be scanned
*
* @return array Array of absolute paths to migration directories
*/
public static function get_all_paths(): array
{
return [
database_path('migrations'),
base_path('rsx/resource/migrations'),
];
}
/**
* Get the default path for new migrations created via make:migration:safe
*
* @return string Absolute path to default migration directory
*/
public static function get_default_path(): string
{
return database_path('migrations');
}
/**
* Ensure all migration directories exist
*
* @return void
*/
public static function ensure_directories_exist(): void
{
foreach (static::get_all_paths() as $path) {
if (!is_dir($path)) {
mkdir($path, 0755, true);
}
}
}
/**
* Get all migration files from all paths, sorted by name (timestamp)
*
* @return array Array of absolute file paths
*/
public static function get_all_migration_files(): array
{
$files = [];
foreach (static::get_all_paths() as $path) {
if (is_dir($path)) {
$path_files = glob($path . '/*.php');
if ($path_files) {
$files = array_merge($files, $path_files);
}
}
}
// Sort by filename (timestamp)
sort($files);
return $files;
}
}

View File

@@ -0,0 +1,385 @@
<?php
namespace App\RSpade\Core\Database;
use Illuminate\Database\Migrations\MigrationRepositoryInterface;
use RuntimeException;
use App\RSpade\Core\Database\MigrationPaths;
/**
* Migration Validator - Enforces raw SQL usage in migrations
*
* This validator ensures migrations use only DB::statement() for schema changes,
* not Laravel's Schema builder. Also removes down() methods from migration files.
*/
class MigrationValidator
{
/**
* Validate a migration file for Schema builder usage
*
* @param string $filepath Path to migration file
* @return void
* @throws RuntimeException if validation fails
*/
public static function validate_migration_file(string $filepath): void
{
if (!file_exists($filepath)) {
throw new RuntimeException("Migration file not found: {$filepath}");
}
$content = file_get_contents($filepath);
$tokens = token_get_all($content);
// Check for Schema builder usage
self::__check_for_schema_builder($tokens, $content, $filepath);
}
/**
* Check tokens for Schema builder usage
*/
private static function __check_for_schema_builder(array $tokens, string $content, string $filepath): void
{
$forbidden_patterns = [
'Schema::create' => 'Use DB::statement("CREATE TABLE...") instead',
'Schema::table' => 'Use DB::statement("ALTER TABLE...") instead',
'Schema::drop' => 'Use DB::statement("DROP TABLE...") instead',
'Schema::dropIfExists' => 'Use DB::statement("DROP TABLE IF EXISTS...") instead',
'Schema::rename' => 'Use DB::statement("RENAME TABLE...") instead',
'Blueprint' => 'Use raw SQL instead of Blueprint',
'$table->' => 'Use raw SQL column definitions instead',
];
$lines = explode("\n", $content);
$in_schema_block = false;
$schema_start_line = null;
$current_line = 1;
$method_buffer = '';
for ($i = 0; $i < count($tokens); $i++) {
$token = $tokens[$i];
// Track line numbers
if (is_array($token) && $token[0] === T_WHITESPACE) {
$current_line += substr_count($token[1], "\n");
continue;
}
// Check for Schema:: usage
if (is_array($token) && $token[0] === T_STRING && $token[1] === 'Schema') {
// Look ahead for ::
$j = $i + 1;
while ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) {
$j++;
}
if ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_DOUBLE_COLON) {
// Found Schema::
$j++;
while ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) {
$j++;
}
if ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) {
$method = $tokens[$j][1];
// Check if it's a forbidden method
$pattern_key = "Schema::{$method}";
if ($method !== 'hasTable' && $method !== 'hasColumn' && $method !== 'getColumnListing') {
// This is a forbidden Schema method
$in_schema_block = true;
$schema_start_line = $current_line;
$method_buffer = "Schema::{$method}";
}
}
}
}
// Check for Blueprint usage
if (is_array($token) && $token[0] === T_STRING && $token[1] === 'Blueprint') {
$in_schema_block = true;
$schema_start_line = $current_line;
$method_buffer = 'Blueprint';
}
// Check for $table-> usage
if (is_array($token) && $token[0] === T_VARIABLE && $token[1] === '$table') {
$j = $i + 1;
while ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) {
$j++;
}
if ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_OBJECT_OPERATOR) {
$in_schema_block = true;
$schema_start_line = $current_line;
$method_buffer = '$table->';
}
}
// If we found a schema block, extract it
if ($in_schema_block) {
self::__report_schema_violation($filepath, $schema_start_line, $lines, $method_buffer, $forbidden_patterns);
return; // Stop at first violation
}
}
}
/**
* Report a Schema builder violation with colored output
*/
private static function __report_schema_violation(
string $filepath,
int $line_number,
array $lines,
string $pattern,
array $forbidden_patterns
): void {
echo "\n";
echo "\033[1;31m❌ Migration Validation Failed\033[0m\n";
echo "\n";
echo "\033[33mFile:\033[0m " . basename($filepath) . "\n";
echo "\033[33mLine:\033[0m {$line_number}\n";
echo "\n";
echo "\033[1;37mViolation:\033[0m Found forbidden Schema builder usage: \033[31m{$pattern}\033[0m\n";
echo "\n";
// Show code preview - find the complete statement
echo "\033[1;37mCode Preview:\033[0m\n";
echo "\033[90m" . str_repeat("", 60) . "\033[0m\n";
$preview = self::__extract_statement($lines, $line_number - 1);
echo "\033[36m" . $preview . "\033[0m\n";
echo "\033[90m" . str_repeat("", 60) . "\033[0m\n";
echo "\n";
// Get remediation advice
$remediation = 'Use raw DB::statement() instead';
foreach ($forbidden_patterns as $key => $advice) {
if (strpos($pattern, str_replace(['Schema::', '$table->'], '', $key)) !== false) {
$remediation = $advice;
break;
}
}
echo "\033[1;32mRemediation:\033[0m {$remediation}\n";
echo "\n";
echo "\033[1;37mExample:\033[0m\n";
if (strpos($pattern, 'create') !== false) {
echo " DB::statement('CREATE TABLE users (\n";
echo " id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,\n";
echo " name VARCHAR(255),\n";
echo " created_at TIMESTAMP NULL DEFAULT NULL\n";
echo " )');\n";
} elseif (strpos($pattern, 'table') !== false || strpos($pattern, 'Blueprint') !== false) {
echo " DB::statement('ALTER TABLE users ADD COLUMN age BIGINT NULL');\n";
} elseif (strpos($pattern, 'drop') !== false) {
echo " DB::statement('DROP TABLE IF EXISTS users');\n";
}
echo "\n";
echo "\033[1;31mMigrations must use raw SQL queries, not Laravel's Schema builder.\033[0m\n";
echo "\n";
throw new RuntimeException("Migration validation failed: Schema builder usage detected");
}
/**
* Extract complete statement from lines starting at given position
*/
private static function __extract_statement(array $lines, int $start_line): string
{
$statement = '';
$brace_count = 0;
$found_start = false;
for ($i = $start_line; $i < count($lines); $i++) {
$line = $lines[$i];
$statement .= $line . "\n";
// Count braces to find complete statement
for ($j = 0; $j < strlen($line); $j++) {
if ($line[$j] === '{') {
$brace_count++;
$found_start = true;
} elseif ($line[$j] === '}') {
$brace_count--;
if ($found_start && $brace_count === 0) {
return trim($statement);
}
} elseif ($line[$j] === ';' && $brace_count === 0) {
return trim($statement);
}
}
// Stop at 20 lines to prevent huge output
if ($i - $start_line > 20) {
return trim($statement) . "\n ...";
}
}
return trim($statement);
}
/**
* Remove down() method from a migration file
*/
public static function remove_down_method(string $filepath): bool
{
if (!file_exists($filepath)) {
return false;
}
$content = file_get_contents($filepath);
$tokens = token_get_all($content);
$down_start_pos = null;
$down_end_pos = null;
$brace_count = 0;
$in_down = false;
$positions = [];
$current_pos = 0;
// Build position map for each token
for ($i = 0; $i < count($tokens); $i++) {
$positions[$i] = $current_pos;
if (is_array($tokens[$i])) {
$current_pos += strlen($tokens[$i][1]);
} else {
$current_pos += strlen($tokens[$i]);
}
}
for ($i = 0; $i < count($tokens); $i++) {
$token = $tokens[$i];
// Look for "function down"
if (is_array($token) && $token[0] === T_FUNCTION) {
$j = $i + 1;
while ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_WHITESPACE) {
$j++;
}
if ($j < count($tokens) && is_array($tokens[$j]) && $tokens[$j][0] === T_STRING && $tokens[$j][1] === 'down') {
// Found down() function
// Look backwards to find start position including visibility modifier and comments
$start_index = $i;
// Look for visibility modifier (public, protected, private)
for ($k = $i - 1; $k >= 0; $k--) {
$prev = $tokens[$k];
if (is_array($prev)) {
if ($prev[0] === T_PUBLIC || $prev[0] === T_PROTECTED || $prev[0] === T_PRIVATE) {
$start_index = $k;
break;
} elseif ($prev[0] === T_COMMENT || $prev[0] === T_DOC_COMMENT) {
$start_index = $k;
} elseif ($prev[0] !== T_WHITESPACE) {
break;
}
} else {
break;
}
}
// Look for comments/whitespace before visibility or function
for ($k = $start_index - 1; $k >= 0; $k--) {
$prev = $tokens[$k];
if (is_array($prev)) {
if ($prev[0] === T_COMMENT || $prev[0] === T_DOC_COMMENT || $prev[0] === T_WHITESPACE) {
$start_index = $k;
} else {
break;
}
} else {
break;
}
}
$down_start_pos = $positions[$start_index];
$in_down = true;
$brace_count = 0;
}
}
// Track braces to find end of down() method
if ($in_down) {
// Skip string interpolation braces
if (is_array($token) && $token[0] === T_CURLY_OPEN) {
continue;
}
if (is_string($token)) {
if ($token === '{') {
$brace_count++;
} elseif ($token === '}') {
// Check if this is closing string interpolation
$is_string_interpolation = false;
for ($j = $i - 1; $j >= max(0, $i - 10); $j--) {
if (is_array($tokens[$j]) && $tokens[$j][0] === T_CURLY_OPEN) {
$is_string_interpolation = true;
break;
}
}
if (!$is_string_interpolation) {
$brace_count--;
if ($brace_count === 0) {
// Found closing brace of down() method
$down_end_pos = $positions[$i] + 1; // Include the closing brace
$in_down = false;
break;
}
}
}
}
}
}
// If we found down() method, remove it
if ($down_start_pos !== null && $down_end_pos !== null) {
$before = substr($content, 0, $down_start_pos);
$after = substr($content, $down_end_pos);
// Clean up trailing whitespace from before section
$before = rtrim($before);
// Ensure exactly one newline before closing brace
$new_content = $before . "\n};\n";
file_put_contents($filepath, $new_content);
return true;
}
return false;
}
/**
* Get pending migrations that haven't been run yet
*/
public static function get_pending_migrations(MigrationRepositoryInterface $repository): array
{
$ran = $repository->getRan();
$migration_files = [];
// Scan all migration paths
foreach (MigrationPaths::get_all_paths() as $path) {
if (!is_dir($path)) {
continue;
}
$files = scandir($path);
foreach ($files as $file) {
if (pathinfo($file, PATHINFO_EXTENSION) !== 'php') {
continue;
}
$name = str_replace('.php', '', $file);
if (!in_array($name, $ran)) {
$migration_files[] = $path . '/' . $file;
}
}
}
return $migration_files;
}
}

View File

@@ -0,0 +1,229 @@
<?php
namespace App\RSpade\Core\Database;
use App\RSpade\Core\Manifest\Manifest;
/**
* Database helper for accessing model and table metadata from the manifest
*/
class ModelHelper
{
/**
* Get all model class names in the system
*
* @return array Array of model class names (simple names, not FQCNs)
*/
public static function get_all_model_names(): array
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'])) {
return [];
}
return array_keys($manifest['data']['models']);
}
/**
* Get all database table names used by models
*
* @return array Array of table names
*/
public static function get_all_table_names(): array
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'])) {
return [];
}
$tables = [];
foreach ($manifest['data']['models'] as $model_data) {
if (isset($model_data['table'])) {
$tables[] = $model_data['table'];
}
}
return array_unique($tables);
}
/**
* Get full column data for a model by class name
*
* @param string $model_name Simple class name (e.g., 'User', not 'App\Models\User')
* @return array Column metadata array
* @throws \RuntimeException if model not found
*/
public static function get_columns_by_model(string $model_name): array
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'][$model_name])) {
throw new \RuntimeException("Model not found in manifest: {$model_name}");
}
return $manifest['data']['models'][$model_name]['columns'] ?? [];
}
/**
* Get full column data for a database table
*
* @param string $table_name Database table name
* @return array Column metadata array
* @throws \RuntimeException if table not found
*/
public static function get_columns_by_table(string $table_name): array
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'])) {
throw new \RuntimeException("No models found in manifest");
}
foreach ($manifest['data']['models'] as $model_data) {
if (isset($model_data['table']) && $model_data['table'] === $table_name) {
return $model_data['columns'] ?? [];
}
}
throw new \RuntimeException("Table not found in manifest: {$table_name}");
}
/**
* Get model metadata by class name
*
* @param string $model_name Simple class name
* @return array Full model metadata including table, columns, fqcn, etc.
* @throws \RuntimeException if model not found
*/
public static function get_model_metadata(string $model_name): array
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'][$model_name])) {
throw new \RuntimeException("Model not found in manifest: {$model_name}");
}
return $manifest['data']['models'][$model_name];
}
/**
* Get model name by table name
*
* @param string $table_name Database table name
* @return string Model class name
* @throws \RuntimeException if table not found
*/
public static function get_model_by_table(string $table_name): string
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'])) {
throw new \RuntimeException("No models found in manifest");
}
foreach ($manifest['data']['models'] as $model_name => $model_data) {
if (isset($model_data['table']) && $model_data['table'] === $table_name) {
return $model_name;
}
}
throw new \RuntimeException("No model found for table: {$table_name}");
}
/**
* Get table name by model name
*
* @param string $model_name Simple class name
* @return string Database table name
* @throws \RuntimeException if model not found
*/
public static function get_table_by_model(string $model_name): string
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'][$model_name])) {
throw new \RuntimeException("Model not found in manifest: {$model_name}");
}
if (!isset($manifest['data']['models'][$model_name]['table'])) {
throw new \RuntimeException("Model has no table defined: {$model_name}");
}
return $manifest['data']['models'][$model_name]['table'];
}
/**
* Check if a model exists in the manifest
*
* @param string $model_name Simple class name
* @return bool
*/
public static function model_exists(string $model_name): bool
{
$manifest = Manifest::get_full_manifest();
return isset($manifest['data']['models'][$model_name]);
}
/**
* Check if a table is managed by a model
*
* @param string $table_name Database table name
* @return bool
*/
public static function table_exists(string $table_name): bool
{
$manifest = Manifest::get_full_manifest();
if (!isset($manifest['data']['models'])) {
return false;
}
foreach ($manifest['data']['models'] as $model_data) {
if (isset($model_data['table']) && $model_data['table'] === $table_name) {
return true;
}
}
return false;
}
/**
* Get column type for a specific column in a model
*
* @param string $model_name Simple class name
* @param string $column_name Column name
* @return string Column type
* @throws \RuntimeException if model or column not found
*/
public static function get_column_type(string $model_name, string $column_name): string
{
$columns = static::get_columns_by_model($model_name);
if (!isset($columns[$column_name])) {
throw new \RuntimeException("Column '{$column_name}' not found in model '{$model_name}'");
}
return $columns[$column_name]['type'] ?? 'unknown';
}
/**
* Check if a column is nullable
*
* @param string $model_name Simple class name
* @param string $column_name Column name
* @return bool
* @throws \RuntimeException if model or column not found
*/
public static function is_column_nullable(string $model_name, string $column_name): bool
{
$columns = static::get_columns_by_model($model_name);
if (!isset($columns[$column_name])) {
throw new \RuntimeException("Column '{$column_name}' not found in model '{$model_name}'");
}
return $columns[$column_name]['nullable'] ?? false;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,451 @@
<?php
namespace App\RSpade\Core\Database\Models;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use RuntimeException;
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
use App\RSpade\Core\Locks\RsxLocks;
use App\RSpade\Core\Session\Session;
/**
* Abstract base model for site-scoped models with automatic concurrency control
*
* Models extending this class:
* - Automatically scope queries by site_id from session
* - Include site_id column in the database
* - Support soft deletes when configured
* - Provide automatic site-level database locking for write operations
* - Strict enforcement of site boundaries - no cross-site data access
*
* SITE ISOLATION:
* - All queries automatically filtered by current session site_id
* - All saves automatically set site_id from session
* - Changing site_id on existing records is FATAL
* - Site ID 0 used when no site in session (global/unscoped data)
* - No caching of site_id - always reads fresh from session
*
* CONCURRENCY CONTROL:
* Site locks are acquired when Session::get_site_id() is called:
* - Each site gets a READ lock when its site_id is accessed
* - First save() operation upgrades to WRITE lock automatically
* - No automatic transactions - handle manually when needed
*
* When config('rsx.locking.site_always_write') is true:
* - ALL requests start with WRITE lock (no read locks ever)
* - Completely serializes all requests for a given site
* - Severe performance impact - use only for critical operations
* - Displays warning on stderr in production CLI mode
*
* This prevents race conditions for critical operations like:
* - Inventory management
* - Auction bidding
* - Financial transactions
* - Any operation requiring strict consistency within a site
*/
abstract class Rsx_Site_Model_Abstract extends Rsx_Model_Abstract
{
/**
* Whether to automatically apply site scoping to queries
* Can be disabled for admin operations that need cross-site access
*
* @var bool
*/
protected static $apply_site_scope = true;
/**
* Site lock tokens by site_id
* @var array<int, string>
*/
protected static $site_lock_tokens = [];
/**
* Whether each site lock has been upgraded to write
* @var array<int, bool>
*/
protected static $site_lock_is_write = [];
/**
* Get the current site ID from session
* Always returns fresh value - never cached
*
* @return int
*/
public static function get_current_site_id(): int
{
$site_id = Session::get_site_id();
// Use site_id 0 if null/empty (global scope)
if ($site_id === null || $site_id === '') {
return 0;
}
return (int)$site_id;
}
/**
* Acquire a read lock for a specific site ID
* Called from Session::get_site_id() when site_id is accessed
*
* @param int $site_id The site ID to lock (defaults to 0)
* @return void
*/
public static function acquire_site_lock_for_id(int $site_id): void
{
// Don't lock if we already have a lock for this site
if (isset(static::$site_lock_tokens[$site_id])) {
return;
}
$always_write = config('rsx.locking.site_always_write', false);
// Issue warning if always_write_lock is enabled in production CLI mode
if ($always_write && php_sapi_name() === 'cli' && app()->environment('production')) {
fwrite(STDERR, "\033[33mWARNING: RSX_SITE_ALWAYS_WRITE is enabled in production. " .
'All site requests are serialized with exclusive write locks. ' .
"This severely impacts performance and should only be used for critical operations.\033[0m\n");
}
// Determine lock type based on configuration
$lock_type = $always_write ? RsxLocks::WRITE_LOCK : RsxLocks::READ_LOCK;
// Acquire lock for this site
static::$site_lock_tokens[$site_id] = RsxLocks::get_lock(
RsxLocks::DATABASE_LOCK,
RsxLocks::LOCK_SITE_PREFIX . $site_id,
$lock_type,
config('rsx.locking.timeout', 30)
);
// If we started with a write lock, mark it as such
static::$site_lock_is_write[$site_id] = $always_write;
// Register shutdown handler to cleanup (only once)
static $shutdown_registered = false;
if (!$shutdown_registered) {
register_shutdown_function([static::class, 'release_all_site_locks']);
$shutdown_registered = true;
}
}
/**
* Upgrade site lock from read to write
* Called automatically on first save() operation
*
* @return void
*/
protected static function __upgrade_to_write_lock(): void
{
$site_id = static::get_current_site_id();
// If no lock for this site yet, acquire write lock directly
if (!isset(static::$site_lock_tokens[$site_id])) {
static::$site_lock_tokens[$site_id] = RsxLocks::get_lock(
RsxLocks::DATABASE_LOCK,
RsxLocks::LOCK_SITE_PREFIX . $site_id,
RsxLocks::WRITE_LOCK,
config('rsx.locking.timeout', 30)
);
static::$site_lock_is_write[$site_id] = true;
return;
}
// Already have write lock
if (isset(static::$site_lock_is_write[$site_id]) && static::$site_lock_is_write[$site_id]) {
return;
}
// Upgrade read to write
try {
static::$site_lock_tokens[$site_id] = RsxLocks::upgrade_lock(
static::$site_lock_tokens[$site_id],
config('rsx.locking.timeout', 30)
);
static::$site_lock_is_write[$site_id] = true;
} catch (RuntimeException $e) {
throw new RuntimeException(
"Failed to upgrade site lock to write mode for site {$site_id}: " . $e->getMessage()
);
}
}
/**
* Release a specific site's lock
*
* @param int $site_id The site ID to release lock for
* @return void
*/
public static function release_site_lock(int $site_id): void
{
if (isset(static::$site_lock_tokens[$site_id])) {
try {
RsxLocks::release_lock(static::$site_lock_tokens[$site_id]);
} catch (Exception $e) {
// Ignore errors during cleanup
}
unset(static::$site_lock_tokens[$site_id]);
unset(static::$site_lock_is_write[$site_id]);
}
}
/**
* Release all site locks
* Called automatically on shutdown
*
* @return void
*/
public static function release_all_site_locks(): void
{
foreach (static::$site_lock_tokens as $site_id => $token) {
try {
RsxLocks::release_lock($token);
} catch (Exception $e) {
// Ignore errors during cleanup
}
}
static::$site_lock_tokens = [];
static::$site_lock_is_write = [];
}
/**
* Temporarily disable site scoping for admin operations
*
* @param callable $callback
* @return mixed
*/
public static function without_site_scope(callable $callback)
{
$was_applying = static::$apply_site_scope;
static::$apply_site_scope = false;
try {
return $callback();
} finally {
static::$apply_site_scope = $was_applying;
}
}
/**
* Boot the model and add global scope for site_id
*/
protected static function booted()
{
parent::booted();
// Add global scope to filter by site_id
static::addGlobalScope('site', function (Builder $builder) {
if (static::$apply_site_scope) {
$site_id = static::get_current_site_id();
$builder->where($builder->getModel()->getTable() . '.site_id', $site_id);
}
});
// Automatically set site_id when creating new models
static::creating(function ($model) {
if (static::$apply_site_scope) {
// Always set site_id from session, even if already set
// This ensures consistency and prevents injection attacks
$model->site_id = static::get_current_site_id();
}
});
// Validate site_id on save (both create and update)
static::saving(function ($model) {
if (static::$apply_site_scope) {
$current_site_id = static::get_current_site_id();
// For existing records, ensure site_id hasn't changed
if ($model->exists) {
$original_site_id = $model->getOriginal('site_id');
// Fatal error if trying to change site_id
if ($model->site_id != $original_site_id) {
shouldnt_happen(
"Attempted to change site_id from {$original_site_id} to {$model->site_id} " .
'on ' . get_class($model) . " ID {$model->id}. " .
'Changing site_id is not allowed.'
);
}
// Fatal error if record doesn't belong to current site
if ($model->site_id != $current_site_id) {
shouldnt_happen(
'Attempted to save ' . get_class($model) . " ID {$model->id} " .
"with site_id {$model->site_id} but current session site_id is {$current_site_id}. " .
'Cross-site saves are not allowed.'
);
}
} else {
// For new records, force the site_id
$model->site_id = $current_site_id;
}
}
});
// After retrieving records, validate they belong to current site
static::retrieved(function ($model) {
if (static::$apply_site_scope) {
$current_site_id = static::get_current_site_id();
// This shouldn't happen if global scope is working, but double-check
if ($model->site_id != $current_site_id) {
shouldnt_happen(
'Retrieved ' . get_class($model) . " ID {$model->id} " .
"with site_id {$model->site_id} but current session site_id is {$current_site_id}. " .
'Global scope should have prevented this.'
);
}
}
});
}
/**
* Override save to handle site locking
*
* @param array $options
* @return bool
*/
public function save(array $options = [])
{
// Always upgrade to write lock for saves
static::__upgrade_to_write_lock();
// Additional validation before save
if (static::$apply_site_scope) {
$current_site_id = static::get_current_site_id();
// Ensure we're not trying to save a record from wrong site
if ($this->exists && $this->site_id != $current_site_id) {
shouldnt_happen(
'Attempted to save ' . get_class($this) . " with site_id {$this->site_id} " .
"but current session site_id is {$current_site_id}. " .
'This indicates a serious security issue.'
);
}
// Force site_id for new records
if (!$this->exists) {
$this->site_id = $current_site_id;
}
}
return parent::save($options);
}
/**
* Override update to handle site locking
*
* @param array $attributes
* @param array $options
* @return bool
*/
public function update(array $attributes = [], array $options = [])
{
// Fatal if trying to change site_id via update
if (isset($attributes['site_id']) && static::$apply_site_scope) {
if ($attributes['site_id'] != $this->site_id) {
shouldnt_happen(
"Attempted to change site_id via update() from {$this->site_id} to {$attributes['site_id']} " .
'on ' . get_class($this) . " ID {$this->id}. " .
'Changing site_id is never allowed.'
);
}
// Remove site_id from attributes since it shouldn't change
unset($attributes['site_id']);
}
// Always upgrade to write lock for updates
static::__upgrade_to_write_lock();
return parent::update($attributes, $options);
}
/**
* Override delete to handle site locking
*
* @return bool|null
*/
public function delete()
{
// Always upgrade to write lock for updates
static::__upgrade_to_write_lock();
// Validate site ownership before delete
if (static::$apply_site_scope) {
$current_site_id = static::get_current_site_id();
if ($this->site_id != $current_site_id) {
shouldnt_happen(
'Attempted to delete ' . get_class($this) . " ID {$this->id} " .
"with site_id {$this->site_id} but current session site_id is {$current_site_id}. " .
'Cross-site deletes are not allowed.'
);
}
}
return parent::delete();
}
/**
* Scope a query to a specific site
*
* @param Builder $query
* @param int $site_id
* @return Builder
*/
public function scopeForSite($query, $site_id)
{
return $query->where('site_id', $site_id);
}
/**
* Get models for all sites (admin use only)
*
* @return Builder
*/
public static function for_all_sites()
{
return static::without_site_scope(function () {
return static::query();
});
}
/**
* Create a new model instance for a specific site
*
* @param array $attributes
* @param int|null $site_id Override site_id (admin use only)
* @return static
*/
public static function create_for_site(array $attributes = [], ?int $site_id = null)
{
if ($site_id !== null && !static::$apply_site_scope) {
// Admin mode - allow specific site_id
$attributes['site_id'] = $site_id;
} else {
// Normal mode - use session site_id
$attributes['site_id'] = static::get_current_site_id();
}
return static::create($attributes);
}
/**
* Find or create a model for the current site
*
* @param array $attributes
* @param array $values
* @return static
*/
public static function first_or_create_for_site(array $attributes, array $values = [])
{
$site_id = static::get_current_site_id();
$attributes['site_id'] = $site_id;
$values['site_id'] = $site_id;
return static::firstOrCreate($attributes, $values);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\RSpade\Core\Database\Models;
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
/**
* Abstract base model for system/internal models
*
* Models extending this class:
* - Represent server-side only metadata
* - Are never exported to JavaScript ORM
* - Include system data like logs, audit trails, IP addresses, etc.
*
* Examples: ip_addresses, activity_logs, system_settings
*/
abstract class Rsx_System_Model_Abstract extends Rsx_Model_Abstract
{
/**
* Mark this as a system model that should never be exported
*
* @var bool
*/
protected $is_system_model = true;
/**
* System models should never be included in JavaScript ORM exports
*
* @return bool
*/
public function is_exportable_to_javascript()
{
return false;
}
/**
* Get metadata indicating this is a system model
*
* @return array
*/
public function get_system_metadata()
{
return [
'is_system' => true,
'exportable' => false,
'model_type' => 'system',
'description' => 'Internal system model - not exported to client'
];
}
/**
* Override to ensure all columns are marked as never export for system models
*
* @return array
*/
public function get_never_exported_columns()
{
// For system models, ALL columns should never be exported
return array_keys($this->getAttributes());
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\RSpade\Core\Database\Query\Grammars;
/**
* Custom MySQL Grammar with millisecond precision for timestamps
*
* This extends Laravel's MySqlGrammar to support microsecond precision
* in date formats, allowing for more precise timestamp storage and
* comparison in MySQL databases.
*/
#[Instantiatable]
class Query_MySqlGrammar extends \Illuminate\Database\Query\Grammars\MySqlGrammar
{
/**
* Get the format for database stored dates with millisecond precision
*
* @return string
*/
public function getDateFormat()
{
return 'Y-m-d H:i:s.u';
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\RSpade\Core\Database\Schema\Grammars;
/**
* Custom MySQL Schema Grammar with millisecond precision for timestamps
*
* This extends Laravel's MySqlGrammar to support microsecond precision
* in date formats for schema operations, ensuring consistency with the
* Query grammar.
*/
#[Instantiatable]
class Schema_MySqlGrammar extends \Illuminate\Database\Schema\Grammars\MySqlGrammar
{
/**
* Get the format for database stored dates with millisecond precision
*
* @return string
*/
public function getDateFormat()
{
return 'Y-m-d H:i:s.u';
}
}

View File

@@ -0,0 +1,54 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Database;
/**
* Central location for seeder path management
*
* RSpade uses /rsx/resource/seeders as the default location for user seeders.
* The /resource/ directory is excluded from manifest scanning, making it suitable
* for seeders and other framework-related code that doesn't follow RSX conventions.
*/
class SeederPaths
{
/**
* Get the default path for new seeders created via make:seeder
*
* @return string Absolute path to default seeder directory
*/
public static function get_default_path(): string
{
return base_path('rsx/resource/seeders');
}
/**
* Get all seeder directories to scan
*
* @return array Array of absolute paths to seeder directories
*/
public static function get_all_paths(): array
{
return [
static::get_default_path(),
];
}
/**
* Ensure all seeder directories exist
*
* @return void
*/
public static function ensure_directories_exist(): void
{
foreach (static::get_all_paths() as $path) {
if (!is_dir($path)) {
mkdir($path, 0755, true);
}
}
}
}

View File

@@ -0,0 +1,412 @@
<?php
namespace App\RSpade\Core\Database;
/**
* SQL Query Transformer
*
* Enforces RSpade framework schema conventions by transforming DDL statements.
* Intercepts CREATE TABLE and ALTER TABLE queries during migrations to ensure
* consistent column types and character sets.
*
* Type conversions enforced:
* - INT/INTEGER/MEDIUMINT/SMALLINT BIGINT (except TINYINT(1) for booleans)
* - FLOAT/REAL DOUBLE
* - TEXT/MEDIUMTEXT LONGTEXT
* - CHAR(n) VARCHAR(n)
* - TIMESTAMP TIMESTAMP(3), DATETIME DATETIME(3) (millisecond precision)
* - All VARCHAR/TEXT utf8mb4 charset + utf8mb4_unicode_ci collation
*
* Forbidden types (throws exception):
* - ENUM - Use VARCHAR with validation instead
* - SET - Use JSON or separate table
* - YEAR - Use INT or DATE
* - TIME - Use DATETIME
* - CHAR - Use VARCHAR
*
* This eliminates the need for foreign key drop/recreate in normalize_schema
* because tables are created with correct types from the start.
*/
class SqlQueryTransformer
{
/**
* Whether the transformer is currently enabled
*/
private static bool $enabled = false;
/**
* Enable query transformation
*
* Call this at the start of migration runs to activate the transformer.
*/
public static function enable(): void
{
self::$enabled = true;
}
/**
* Disable query transformation
*
* Call this after migration runs complete.
*/
public static function disable(): void
{
self::$enabled = false;
}
/**
* Check if transformer is enabled
*/
public static function is_enabled(): bool
{
return self::$enabled;
}
/**
* Transform a SQL query according to framework conventions
*
* @param string $query The SQL query to transform
* @return string The transformed query
* @throws \RuntimeException If query contains forbidden column types
*/
public static function transform(string $query): string
{
if (!self::$enabled) {
return $query;
}
// Check if this is a CREATE TABLE or ALTER TABLE statement
$normalized = self::__normalize_whitespace($query);
if (!self::__is_ddl_statement($normalized)) {
return $query;
}
// Validate - throw exception for forbidden types
self::__validate_forbidden_types($query);
// Transform the query
$transformed = $query;
// 1. Integer type transformations
$transformed = self::__transform_integer_types($transformed);
// 2. Floating point transformations
$transformed = self::__transform_float_types($transformed);
// 3. Text type transformations
$transformed = self::__transform_text_types($transformed);
// 4. Datetime precision transformations
$transformed = self::__transform_datetime_types($transformed);
// 5. Character set enforcement
$transformed = self::__enforce_utf8mb4($transformed);
return $transformed;
}
/**
* Normalize whitespace in SQL query for easier parsing
*
* - Collapses multiple spaces to single space
* - Preserves quoted strings
* - Removes leading/trailing whitespace
*
* @param string $query The SQL query
* @return string Normalized query
*/
private static function __normalize_whitespace(string $query): string
{
// Remove leading/trailing whitespace
$query = trim($query);
// Collapse multiple spaces, tabs, newlines to single space
// But preserve quoted strings
$result = '';
$in_single_quote = false;
$in_double_quote = false;
$in_backtick = false;
$prev_was_space = false;
for ($i = 0; $i < strlen($query); $i++) {
$char = $query[$i];
// Handle quotes
if ($char === "'" && !$in_double_quote && !$in_backtick) {
$in_single_quote = !$in_single_quote;
$result .= $char;
$prev_was_space = false;
continue;
}
if ($char === '"' && !$in_single_quote && !$in_backtick) {
$in_double_quote = !$in_double_quote;
$result .= $char;
$prev_was_space = false;
continue;
}
if ($char === '`' && !$in_single_quote && !$in_double_quote) {
$in_backtick = !$in_backtick;
$result .= $char;
$prev_was_space = false;
continue;
}
// If we're inside quotes, preserve everything
if ($in_single_quote || $in_double_quote || $in_backtick) {
$result .= $char;
$prev_was_space = false;
continue;
}
// Outside quotes: collapse whitespace
if ($char === ' ' || $char === "\t" || $char === "\n" || $char === "\r") {
if (!$prev_was_space) {
$result .= ' ';
$prev_was_space = true;
}
continue;
}
// Regular character
$result .= $char;
$prev_was_space = false;
}
return $result;
}
/**
* Check if query is a DDL statement we need to transform
*
* @param string $normalized_query Normalized SQL query
* @return bool True if this is CREATE TABLE or ALTER TABLE
*/
private static function __is_ddl_statement(string $normalized_query): bool
{
$upper = strtoupper($normalized_query);
return str_starts_with($upper, 'CREATE TABLE')
|| str_starts_with($upper, 'ALTER TABLE');
}
/**
* Validate that query doesn't contain forbidden column types
*
* @param string $query The SQL query
* @throws \RuntimeException If forbidden types are found
*/
private static function __validate_forbidden_types(string $query): void
{
// Check for ENUM
if (preg_match('/\bENUM\s*\(/i', $query)) {
throw new \RuntimeException(
"ENUM column type is forbidden in RSpade. Use VARCHAR with validation instead.\n" .
"ENUMs cannot be modified without table locks and cause deployment issues.\n" .
"Query: " . substr($query, 0, 200)
);
}
// Check for SET
if (preg_match('/\bSET\s*\(/i', $query)) {
throw new \RuntimeException(
"SET column type is forbidden in RSpade. Use JSON or a separate table instead.\n" .
"Query: " . substr($query, 0, 200)
);
}
// Check for YEAR
if (preg_match('/\bYEAR\b/i', $query)) {
throw new \RuntimeException(
"YEAR column type is forbidden in RSpade. Use INT or DATE instead.\n" .
"Query: " . substr($query, 0, 200)
);
}
// Check for TIME (but allow DATETIME and TIMESTAMP)
if (preg_match('/\bTIME\b(?!STAMP)/i', $query)) {
throw new \RuntimeException(
"TIME column type is forbidden in RSpade. Use DATETIME instead.\n" .
"Query: " . substr($query, 0, 200)
);
}
}
/**
* Transform integer column types to BIGINT
*
* Converts: INT, INTEGER, MEDIUMINT, SMALLINT BIGINT
* Preserves: TINYINT(1) for booleans
* Removes: UNSIGNED attribute (framework uses signed integers only)
*
* @param string $query The SQL query
* @return string Transformed query
*/
private static function __transform_integer_types(string $query): string
{
// INT, INTEGER, MEDIUMINT, SMALLINT → BIGINT
// But NOT TINYINT(1) which is for booleans
// Match: INT, INT(11), INT UNSIGNED, INTEGER, etc.
// Don't match: BIGINT, TINYINT, or words containing these (like HINT, POINT)
// Convert INT variants (but not BIGINT or TINYINT)
$query = preg_replace(
'/\b(?:INT|INTEGER|MEDIUMINT|SMALLINT)(?:\(\d+\))?(?:\s+UNSIGNED)?(?!\w)/i',
'BIGINT',
$query
);
// Convert TINYINT(n) where n != 1 to BIGINT
$query = preg_replace(
'/\bTINYINT\s*\(\s*([2-9]|[1-9]\d+)\s*\)(?:\s+UNSIGNED)?/i',
'BIGINT',
$query
);
// Remove UNSIGNED from BIGINT columns (normalize to signed)
$query = preg_replace(
'/\bBIGINT(?:\(\d+\))?\s+UNSIGNED/i',
'BIGINT',
$query
);
return $query;
}
/**
* Transform floating point types to DOUBLE
*
* Converts: FLOAT, REAL DOUBLE
* Preserves: DECIMAL (for exact precision like money)
*
* @param string $query The SQL query
* @return string Transformed query
*/
private static function __transform_float_types(string $query): string
{
// FLOAT, REAL → DOUBLE
$query = preg_replace(
'/\b(?:FLOAT|REAL)(?:\(\d+(?:,\d+)?\))?(?:\s+UNSIGNED)?/i',
'DOUBLE',
$query
);
return $query;
}
/**
* Transform text column types
*
* Converts: TEXT, MEDIUMTEXT LONGTEXT
* Converts: CHAR(n) VARCHAR(n)
*
* @param string $query The SQL query
* @return string Transformed query
*/
private static function __transform_text_types(string $query): string
{
// TEXT, MEDIUMTEXT, TINYTEXT → LONGTEXT
$query = preg_replace(
'/\b(?:TINY|MEDIUM)?TEXT\b/i',
'LONGTEXT',
$query
);
// CHAR(n) → VARCHAR(n)
$query = preg_replace(
'/\bCHAR\s*\((\d+)\)/i',
'VARCHAR($1)',
$query
);
return $query;
}
/**
* Transform datetime column types to include millisecond precision
*
* Converts: TIMESTAMP TIMESTAMP(3)
* Converts: DATETIME DATETIME(3)
*
* This ensures datetime columns have millisecond precision from the start,
* preventing FK type mismatches when normalize_schema runs later.
*
* @param string $query The SQL query
* @return string Transformed query
*/
private static function __transform_datetime_types(string $query): string
{
// TIMESTAMP without precision → TIMESTAMP(3)
// Don't match if already has precision: TIMESTAMP(3), TIMESTAMP(6)
$query = preg_replace(
'/\bTIMESTAMP\b(?!\s*\()/i',
'TIMESTAMP(3)',
$query
);
// DATETIME without precision → DATETIME(3)
// Don't match if already has precision: DATETIME(3), DATETIME(6)
$query = preg_replace(
'/\bDATETIME\b(?!\s*\()/i',
'DATETIME(3)',
$query
);
return $query;
}
/**
* Enforce utf8mb4 character set on all string columns
*
* 1. Replaces existing latin1/utf8/utf8mb3 with utf8mb4
* 2. Adds CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci to columns without charset
* 3. Replaces table-level DEFAULT CHARSET with utf8mb4
*
* @param string $query The SQL query
* @return string Transformed query
*/
private static function __enforce_utf8mb4(string $query): string
{
// Replace existing CHARACTER SET declarations with utf8mb4
// Match: CHARACTER SET latin1, CHARACTER SET utf8, CHARACTER SET utf8mb3
$query = preg_replace(
'/CHARACTER\s+SET\s+(?:latin1|utf8mb3|utf8)\b/i',
'CHARACTER SET utf8mb4',
$query
);
// Replace existing COLLATE declarations with utf8mb4_unicode_ci
// Match: COLLATE latin1_swedish_ci, COLLATE utf8_general_ci, etc.
$query = preg_replace(
'/COLLATE\s+(?:latin1|utf8mb3|utf8)_\w+/i',
'COLLATE utf8mb4_unicode_ci',
$query
);
// Replace table-level DEFAULT CHARSET
$query = preg_replace(
'/DEFAULT\s+CHARSET\s*=\s*(?:latin1|utf8mb3|utf8)\b/i',
'DEFAULT CHARSET=utf8mb4',
$query
);
// Add charset to VARCHAR(n) that doesn't already have CHARACTER SET
// Negative lookahead allows whitespace and comments before CHARACTER SET check
$query = preg_replace(
'/\bVARCHAR\s*\(\s*\d+\s*\)(?!(?:\s|\/\*.*?\*\/|--[^\n]*)*\s*CHARACTER\s+SET)/i',
'$0 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
$query
);
// Add charset to LONGTEXT that doesn't already have CHARACTER SET
// Negative lookahead allows whitespace and comments before CHARACTER SET check
$query = preg_replace(
'/\bLONGTEXT\b(?!(?:\s|\/\*.*?\*\/|--[^\n]*)*\s*CHARACTER\s+SET)/i',
'$0 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
$query
);
return $query;
}
}

1308
app/RSpade/Core/Debug/Debugger.php Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
<?php
namespace App\RSpade\Core\Debug;
use Illuminate\Http\Request;
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
use App\RSpade\Core\Debug\Debugger;
/**
* Debugger_Controller - API endpoints for debugging features
*
* Handles AJAX requests from JavaScript for console_debug and error logging.
* Delegates to the Debugger utility class for actual implementation.
*/
class Debugger_Controller extends Rsx_Controller_Abstract
{
/**
* Ajax endpoint to log console_debug messages from JavaScript
*
* @param \Illuminate\Http\Request $request
* @param array $params
* @return array
*/
#[Ajax_Endpoint]
public static function log_console_messages(Request $request, array $params = []): array
{
return Debugger::log_console_messages($request, $params);
}
/**
* Ajax endpoint to log browser errors from JavaScript
*
* @param \Illuminate\Http\Request $request
* @param array $params
* @return array
*/
#[Ajax_Endpoint]
public static function log_browser_errors(Request $request, array $params = []): array
{
return Debugger::log_browser_errors($request, $params);
}
}

View File

@@ -0,0 +1,143 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Debug;
use Illuminate\Http\Request;
use Log;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
use App\RSpade\Core\Debug\Debugger;
use App\RSpade\Core\Dispatch\Dispatcher;
use App\RSpade\Core\Exceptions\Rsx_Exception_Handler_Abstract;
/**
* Playwright_ExceptionHandler - Handle exceptions during Playwright test execution
*
* PRIORITY: 30
*
* This handler processes exceptions when the request is from a Playwright test
* (identified by X-Playwright-Test header). It provides plain text error output
* suitable for automated testing, including:
* - Error message, file, and line
* - Stack trace (last 10 calls)
* - Console debug messages (if enabled)
* - Special 404 handling (tries RSX dispatch first)
*
* This handler only runs in non-production environments for security.
*/
class Playwright_Exception_Handler extends Rsx_Exception_Handler_Abstract
{
/**
* Get priority - Playwright handlers run after AJAX but before general web
*
* @return int
*/
public static function get_priority(): int
{
return 30;
}
/**
* Handle exception if request is from Playwright test
*
* @param Throwable $e
* @param Request $request
* @return mixed Plain text response if Playwright request, null otherwise
*/
public function handle(Throwable $e, Request $request)
{
// Only process Playwright test requests in non-production environments
if (app()->environment('production') || !$request->header('X-Playwright-Test')) {
return null;
}
Log::debug('Exception handler triggered for Playwright test, exception: ' . get_class($e));
console_debug('DISPATCH', 'Exception handler triggered for Playwright test, exception:', get_class($e));
// Special handling for 404s - check RSX routes first
if ($e instanceof NotFoundHttpException) {
// Get the requested path
$path = '/' . ltrim($request->path(), '/');
Log::debug("Exception handler: attempting RSX dispatch for path: $path");
console_debug('DISPATCH', 'Exception handler: attempting RSX dispatch for', $path);
// Try RSX dispatch
$response = Dispatcher::dispatch($path, $request->method(), [], $request);
Log::debug('RSX dispatch returned: ' . ($response ? 'response' : 'null'));
// If RSX found a route, return the response
if ($response !== null) {
return $response;
}
// No RSX route found - return 404 as plain text
return response('404 Not Found', 404)
->header('Content-Type', 'text/plain');
}
// Build error output
$error_output = 'Error: ' . $e->getMessage() . "\n";
$error_output .= 'File: ' . $e->getFile() . "\n";
$error_output .= 'Line: ' . $e->getLine() . "\n";
$error_output .= "\n";
$error_output .= "Stack Trace (last 10 calls):\n";
// Get stack trace
$trace = $e->getTrace();
$count = 0;
foreach ($trace as $frame) {
if ($count >= 10) {
break;
}
$file = $frame['file'] ?? 'unknown';
$line = $frame['line'] ?? 0;
$function = $frame['function'] ?? 'unknown';
$class = $frame['class'] ?? '';
$type = $frame['type'] ?? '';
if ($class) {
$function = $class . $type . $function;
}
$error_output .= sprintf(
" #%d %s:%d %s()\n",
$count,
$file,
$line,
$function
);
$count++;
}
// Output console debug messages if enabled
$show_console = env('SHOW_CONSOLE_DEBUG_HTTP', true) ||
(isset($_SERVER['HTTP_X_PLAYWRIGHT_CONSOLE_DEBUG']) && $_SERVER['HTTP_X_PLAYWRIGHT_CONSOLE_DEBUG'] === '1');
if (!app()->environment('production') && $show_console) {
$console_messages = Debugger::_get_console_messages();
if (!empty($console_messages)) {
$error_output .= "\nConsole Debug Messages:\n";
foreach ($console_messages as $message) {
// Messages are now arrays with 'message' key
if (is_array($message) && isset($message['message'])) {
$error_output .= ' ' . $message['message'] . "\n";
} elseif (is_string($message)) {
$error_output .= ' ' . $message . "\n";
}
}
}
}
// Return plain text response
return response($error_output, 500)
->header('Content-Type', 'text/plain');
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\RSpade\Core\Debug;
use RuntimeException;
use Throwable;
/**
* Exception that reports error location from the caller's perspective
*
* Automatically walks back the stack trace to show the error at the calling code's location
* instead of where the exception was thrown. Useful for utility methods that validate inputs
* and want errors to point to the caller's location.
*
* Common use cases:
* - Rsx::Route() when controller class doesn't exist - show the blade template line
* - Validation helpers that want to show caller's code as error source
* - Any utility method where the bug is in the caller's usage, not the utility itself
*
* Example:
* ```php
* // In Rsx.php
* throw new Rsx_Caller_Exception("Class {$class_name} not found in manifest");
* // Error shows: frontend_layout.blade.php:23 instead of Rsx.php:242
* ```
*/
#[Instantiatable]
class Rsx_Caller_Exception extends RuntimeException
{
/**
* Create a new Rsx_Caller_Exception
*
* Automatically determines the caller's file and line by walking back the stack trace.
* By default, goes back 1 frame (immediate caller). Increase $frames_back for deeper utilities.
*
* @param string $message The detailed error message
* @param int $frames_back Number of stack frames to walk back (default: 1 = immediate caller)
* @param int $code Optional error code (default: 0)
* @param Throwable|null $previous Optional previous exception for chaining
*/
public function __construct(string $message = '', int $frames_back = 1, int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
// Get the stack trace to find the caller
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, $frames_back + 2);
// Walk back to the desired frame
// Frame 0 is this constructor
// Frame 1 is the immediate caller (default)
// Frame 2+ is deeper callers
$target_frame = $trace[$frames_back] ?? null;
if ($target_frame !== null) {
// Override file and line to point to the caller
if (isset($target_frame['file'])) {
$this->file = $target_frame['file'];
}
if (isset($target_frame['line'])) {
$this->line = $target_frame['line'];
}
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\RSpade\Core\Debug;
use RuntimeException;
use Throwable;
/**
* Exception for errors at a specific file and line number
*
* Used to represent errors that occur at a specific file location different
* from where the exception is thrown. Common use cases:
* - Syntax errors detected in custom parsers/linters
* - Template compilation errors pointing to template files
* - Configuration file validation errors
* - Any custom validation where the error location differs from detection location
*
* The file and line parameters override the exception's built-in file/line tracking,
* allowing error handlers (like Ignition) to show the correct file preview.
*/
#[Instantiatable]
class Rsx_File_Exception extends RuntimeException
{
/**
* Create a new Rsx_File_Exception
*
* @param string $message The detailed error message
* @param int $code Optional error code (default: 0)
* @param Throwable|null $previous Optional previous exception for chaining
* @param string|null $file Optional file path to show as the error source
* @param int|null $line Optional line number to show as the error source
*/
public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null, ?string $file = null, ?int $line = null)
{
parent::__construct($message, $code, $previous);
// Override the file and line if provided
if ($file !== null) {
$this->file = $file;
}
if ($line !== null) {
$this->line = $line;
}
}
}

View File

@@ -0,0 +1,273 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Dispatch;
use Exception;
use Illuminate\Http\Request;
use App\RSpade\Core\Ajax\Ajax;
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
/**
* Internal API route handler
*
* Thin wrapper that routes /_ajax/:controller/:action requests to the Ajax class.
* The actual logic is consolidated in Ajax::handle_browser_request() for better organization.
*/
class Ajax_Endpoint_Controller extends Rsx_Controller_Abstract
{
/**
* Handle internal API requests
*
* Routes /_ajax/:controller/:action to Ajax::handle_browser_request()
*/
#[Auth('Permission::anybody()')]
#[Route('/_ajax/:controller/:action', methods: ['POST'])]
public static function dispatch(Request $request, array $params = [])
{
// Delegate all logic to the consolidated Ajax class
return Ajax::handle_browser_request($request, $params);
}
/**
* Set AJAX response mode (backward compatibility)
* @deprecated Use Ajax::set_ajax_response_mode() instead
*/
public static function set_ajax_response_mode(bool $enabled): void
{
Ajax::set_ajax_response_mode($enabled);
}
/**
* Check if AJAX response mode is enabled (backward compatibility)
* @deprecated Use Ajax::is_ajax_response_mode() instead
*/
public static function is_ajax_response_mode(): bool
{
return Ajax::is_ajax_response_mode();
}
/**
* Handle model fetch requests from JavaScript ORM
*
* Routes /_fetch/:model to the model's fetch() method
* Model must have #[Ajax_Endpoint_Model_Fetch] annotation on its fetch() method
*/
#[Auth('Permission::anybody()')]
#[Route('/_fetch/:model', methods: ['POST'])]
public static function fetch_model(Request $request, array $params = [])
{
$model_name = $params['model'] ?? null;
if (!$model_name) {
return response()->json(['error' => 'Model name is required'], 400);
}
// Look up the model class from manifest
try {
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
$model_class = null;
// Find the model class in manifest
foreach ($manifest as $file_path => $metadata) {
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
$model_class = $metadata['fqcn'] ?? null;
break;
}
}
if (!$model_class) {
return response()->json(['error' => "Model {$model_name} not found"], 404);
}
// Check if model extends Rsx_Model_Abstract using manifest
$extends = $model_metadata['extends'] ?? null;
$is_rsx_model = false;
// Check direct extension
if ($extends === 'Rsx_Model_Abstract') {
$is_rsx_model = true;
} else {
// Check indirect extension via manifest
$extending_models = \App\RSpade\Core\Manifest\Manifest::php_get_extending('Rsx_Model_Abstract');
foreach ($extending_models as $extending_model) {
if ($extending_model['class'] === $model_name) {
$is_rsx_model = true;
break;
}
}
}
if (!$is_rsx_model) {
return response()->json(['error' => "Model {$model_name} does not extend Rsx_Model_Abstract"], 403);
}
// Check if fetch() method has the Ajax_Endpoint_Model_Fetch attribute using manifest
$model_metadata = null;
foreach ($manifest as $file_path => $metadata) {
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
$model_metadata = $metadata;
break;
}
}
if (!$model_metadata) {
return response()->json(['error' => "Model {$model_name} metadata not found in manifest"], 500);
}
// Check if fetch method exists and has the attribute
$has_fetch_attribute = false;
if (isset($model_metadata['public_static_methods']['fetch'])) {
$fetch_method = $model_metadata['public_static_methods']['fetch'];
if (isset($fetch_method['attributes'])) {
// Check if Ajax_Endpoint_Model_Fetch attribute exists
// Attributes are stored as associative array with attribute name as key
if (isset($fetch_method['attributes']['Ajax_Endpoint_Model_Fetch'])) {
$has_fetch_attribute = true;
}
}
}
if (!$has_fetch_attribute) {
return response()->json(['error' => "Model {$model_name} fetch() method missing Ajax_Endpoint_Model_Fetch attribute"], 403);
}
// Get the ID parameter
$id = $request->input('id');
if ($id === null) {
return response()->json(['error' => 'ID parameter is required'], 400);
}
// Handle arrays by calling fetch() for each ID
if (is_array($id)) {
$results = [];
foreach ($id as $single_id) {
$model = $model_class::fetch($single_id);
if ($model !== false) {
// Convert to array if it's an object
if (is_object($model) && method_exists($model, 'toArray')) {
$results[] = $model->toArray();
} else {
$results[] = $model;
}
}
}
return response()->json($results);
}
// Single ID - call fetch() directly
$result = $model_class::fetch($id);
if ($result === false) {
return response()->json(false);
}
// Convert to array for JSON response
if (is_object($result) && method_exists($result, 'toArray')) {
return response()->json($result->toArray());
}
// Return as-is if not an object with toArray
return response()->json($result);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
/**
* Handle model relationship fetch requests
*
* Routes /_fetch_rel/:model/:id/:relationship to fetch relationship data
* Model must have the relationship defined
*/
#[Auth('Permission::anybody()')]
#[Route('/_fetch_rel/:model/:id/:relationship', methods: ['POST'])]
public static function fetch_relationship(Request $request, array $params = [])
{
$model_name = $params['model'] ?? null;
$id = $params['id'] ?? null;
$relationship = $params['relationship'] ?? null;
if (!$model_name || !$id || !$relationship) {
return response()->json(['error' => 'Model name, ID, and relationship are required'], 400);
}
try {
// Look up the model class from manifest
$manifest = \App\RSpade\Core\Manifest\Manifest::get_all();
$model_class = null;
foreach ($manifest as $file_path => $metadata) {
if (isset($metadata['class']) && $metadata['class'] === $model_name) {
$model_class = $metadata['fqcn'] ?? null;
break;
}
}
if (!$model_class) {
return response()->json(['error' => "Model {$model_name} not found"], 404);
}
// Check if model extends Rsx_Model_Abstract using manifest
$extends = $model_metadata['extends'] ?? null;
$is_rsx_model = false;
// Check direct extension
if ($extends === 'Rsx_Model_Abstract') {
$is_rsx_model = true;
} else {
// Check indirect extension via manifest
$extending_models = \App\RSpade\Core\Manifest\Manifest::php_get_extending('Rsx_Model_Abstract');
foreach ($extending_models as $extending_model) {
if ($extending_model['class'] === $model_name) {
$is_rsx_model = true;
break;
}
}
}
if (!$is_rsx_model) {
return response()->json(['error' => "Model {$model_name} does not extend Rsx_Model_Abstract"], 403);
}
// Fetch the base model
$model = $model_class::find($id);
if (!$model) {
return response()->json(['error' => "Record with ID {$id} not found"], 404);
}
// Check if relationship exists using get_relationships()
$relationships = $model_class::get_relationships();
if (!in_array($relationship, $relationships)) {
return response()->json(['error' => "Relationship {$relationship} not found on model {$model_name}"], 404);
}
// Call the relationship method directly and get the results
if (!method_exists($model, $relationship)) {
return response()->json(['error' => "Relationship method {$relationship} does not exist on model {$model_name}"], 500);
}
// Load the relationship and return the data
$result = $model->$relationship;
// Handle different result types
if ($result instanceof \Illuminate\Database\Eloquent\Collection) {
return response()->json($result->map->toArray());
} elseif ($result instanceof \Illuminate\Database\Eloquent\Model) {
return response()->json($result->toArray());
} elseif ($result === null) {
return response()->json(null);
}
return response()->json(['error' => "Unable to load relationship {$relationship}"], 500);
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
}

View File

@@ -0,0 +1,352 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Dispatch;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\RSpade\Core\Manifest\Manifest;
/**
* ApiHandler manages API request processing for RSX dispatch system
*
* OPINIONATED API DESIGN PRINCIPLES:
* ===================================
* 1. ALL external API requests return JSON with application/json content-type
* 2. NO content negotiation - APIs always return JSON, period
* 3. Internal API calls return PHP objects/arrays directly
* 4. This is non-negotiable framework behavior
*
* INTERNAL API EXECUTION ARCHITECTURE (Future Implementation):
* =============================================================
*
* This handler will support internal API execution, allowing PHP code to call
* API endpoints directly without HTTP requests. The architecture will work as follows:
*
* 1. INVOCATION METHOD:
* API::execute("ControllerClass.method", $params)
* - Directly calls API controller methods
* - Returns PHP objects/arrays instead of JSON
* - Uses same code path as external requests
*
* 2. SESSION STACK MANAGEMENT:
* When an internal API call is made:
* a. Current user session is preserved on a stack
* b. New session context is created (starts unauthenticated)
* c. API executes with fresh session scope
* d. Session is popped and original restored after completion
*
* 3. AUTHENTICATION FLOW:
* Internal API calls authenticate via:
* - Explicit API token/key in parameters
* - Inherited from current session (if no token provided)
* - Each nested call maintains its own auth context
*
* 4. NESTED CALL SUPPORT:
* - API A can call API B which can call API C
* - Each maintains separate session scope
* - Stack ensures proper restoration on unwind
* - Prevents session pollution between calls
*
* 5. IMPLEMENTATION BENEFITS:
* - Reuse API logic for internal operations
* - Consistent behavior internal/external
* - Proper session isolation
* - Service-oriented architecture in monolith
*
* EXAMPLE USAGE (Future):
* ```php
* // Internal call with current user context
* $result = API::execute('UserApi.get_profile', ['user_id' => 123]);
*
* // Internal call with specific API key
* $result = API::execute('DataApi.export', [
* '_api_key' => 'secret_key',
* 'format' => 'csv'
* ]);
*
* // Nested internal calls work automatically
* // UserApi.get_full_profile might internally call:
* // - PostApi.get_user_posts
* // - CommentApi.get_user_comments
* // Each with proper session isolation
* ```
*
* TODO: Implement session stack when authentication system is ready
* TODO: Add API token/key authentication mechanism
* TODO: Create API::execute() static helper method
* TODO: Add request/response interceptors for internal calls
* TODO: Implement circuit breaker for recursive call protection
*/
class ApiHandler
{
/**
* API version header name
*
* @var string
*/
protected static $version_header = 'X-API-Version';
/**
* Handle API request
*
* @param mixed $handler The handler (controller/method)
* @param Request $request
* @param array $params
* @param array $attributes Route attributes
* @return \Illuminate\Http\Response
*/
public static function handle($handler, Request $request, array $params = [], array $attributes = [])
{
// Add API-specific parameters
$params = static::__prepare_api_params($params, $request);
// Check API version if specified
$version = static::__get_api_version($request, $attributes);
if ($version) {
$params['_api_version'] = $version;
}
// Execute the handler
$result = static::__execute_handler($handler, $request, $params);
// ALWAYS return JSON for external API requests
return static::__create_json_response($result, $request);
}
/**
* Execute API handler internally (for future internal API calls)
*
* @param string $endpoint Format: "ControllerClass.method"
* @param array $params
* @param array $options
* @return mixed Raw PHP response (not JSON)
*/
public static function execute_internal($endpoint, array $params = [], array $options = [])
{
// Parse endpoint
list($class, $method) = static::__parse_endpoint($endpoint);
// TODO: Push new session context onto stack
// $this->push_session_context($options);
try {
// Build handler callable
if (!class_exists($class)) {
throw new \InvalidArgumentException("API class not found: {$class}");
}
$handler = [$class, $method];
if (!is_callable($handler)) {
throw new \InvalidArgumentException("API method not callable: {$endpoint}");
}
// Add internal execution flag
$params['_internal_call'] = true;
$params['_caller_session'] = $options['session'] ?? null;
// Execute handler directly
$result = call_user_func($handler, $params);
// For internal calls, return raw data (not JSON response)
if ($result instanceof JsonResponse) {
return json_decode($result->getContent(), true);
}
return $result;
} finally {
// TODO: Pop session context from stack
// $this->pop_session_context();
}
}
/**
* Parse endpoint string into class and method
*
* @param string $endpoint
* @return array [class, method]
*/
protected static function __parse_endpoint($endpoint)
{
$parts = explode('.', $endpoint);
if (count($parts) !== 2) {
throw new \InvalidArgumentException("Invalid endpoint format. Expected: ControllerClass.method");
}
$class = $parts[0];
$method = $parts[1];
// Add namespace if not present
if (!str_contains($class, '\\')) {
// Check common API namespaces using Manifest
$namespaces = [
'App\\Http\\Controllers\\Api\\',
'App\\RSpade\\Api\\',
'App\\Api\\'
];
$found = false;
foreach ($namespaces as $namespace) {
try {
$full_class = $namespace . $class;
$metadata = Manifest::php_get_metadata_by_fqcn($full_class);
$class = $metadata['fqcn'];
$found = true;
break;
} catch (\RuntimeException $e) {
// Try next namespace
}
}
if (!$found) {
// Try without namespace prefix
try {
$metadata = Manifest::php_get_metadata_by_class($class);
$class = $metadata['fqcn'];
} catch (\RuntimeException $e) {
// Class will be used as-is, may fail later
}
}
}
return [$class, $method];
}
/**
* Execute the API handler
*
* @param mixed $handler
* @param Request $request
* @param array $params
* @return mixed
*/
protected static function __execute_handler($handler, Request $request, array $params)
{
// Handle different handler types
if (is_string($handler) && str_contains($handler, '@')) {
// Laravel style "Controller@method"
list($class, $method) = explode('@', $handler);
$handler = [new $class, $method];
}
if (!is_callable($handler)) {
throw new \RuntimeException("API handler is not callable");
}
// Call handler with request and params
$result = call_user_func($handler, $request, $params);
// Ensure we have a result
if ($result === null) {
$result = ['success' => true];
}
return $result;
}
/**
* Create JSON response for API result
*
* @param mixed $data
* @param Request $request
* @return JsonResponse
*/
protected static function __create_json_response($data, Request $request)
{
$response = new JsonResponse($data);
// Pretty print in development for debugging
if (app()->environment('local', 'development')) {
$response->setEncodingOptions(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
// Always set JSON content type explicitly
$response->headers->set('Content-Type', 'application/json');
return $response;
}
/**
* Prepare API-specific parameters
*
* @param array $params
* @param Request $request
* @return array
*/
protected static function __prepare_api_params(array $params, Request $request)
{
// Add pagination parameters
if ($request->has('page')) {
$params['_page'] = (int) $request->get('page', 1);
$params['_per_page'] = (int) $request->get('per_page', 25);
}
// Add sorting parameters
if ($request->has('sort')) {
$params['_sort'] = $request->get('sort');
$params['_order'] = $request->get('order', 'asc');
}
// Add field filtering
if ($request->has('fields')) {
$params['_fields'] = explode(',', $request->get('fields'));
}
// Add search parameter
if ($request->has('q') || $request->has('search')) {
$params['_search'] = $request->get('q') ?: $request->get('search');
}
// Add API key if present
if ($request->has('api_key')) {
$params['_api_key'] = $request->get('api_key');
} elseif ($request->header('X-API-Key')) {
$params['_api_key'] = $request->header('X-API-Key');
}
return $params;
}
/**
* Get API version from request or attributes
*
* @param Request $request
* @param array $attributes
* @return string|null
*/
protected static function __get_api_version(Request $request, array $attributes)
{
// Check route attribute
foreach ($attributes as $attr) {
if ($attr instanceof \App\RSpade\Core\Attributes\ApiVersion) {
return $attr->version;
}
}
// Check header
if ($request->hasHeader(static::$version_header)) {
return $request->header(static::$version_header);
}
// Check URL path (e.g., /api/v2/...)
$path = $request->path();
if (preg_match('#/v(\d+)/#', $path, $matches)) {
return $matches[1];
}
return null;
}
}

View File

@@ -0,0 +1,722 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Dispatch;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Illuminate\Support\Facades\Log;
/**
* AssetHandler serves static files from RSX public directories
*
* Scans for /rsx/{module}/public/ directories and serves files securely
* with proper cache headers and MIME types
*
* TODO: Unit Tests for Bundle Serving Behavior
* =============================================
* When the framework is more complete, create comprehensive unit tests for:
*
* Development Mode (env('APP_ENV') !== 'production'):
* -------------------------------------------------------------
* 1. Bundle filenames are predictable: app.(hash).js where hash = substr(sha256(FQCN), 0, 32)
* - Example: FrontendBundle -> app.053cded429c46421aea774afff5fbd8b.js
* 2. Query parameters added for cache-busting: ?v=(manifest-hash)
* - Manifest hash is random on each request (bin2hex(random_bytes(16)))
* 3. Bundles compiled on-demand when requested (compile_bundle_on_demand method)
* 4. Cache headers set to no-cache: "Cache-Control: no-cache, no-store, must-revalidate"
* 5. storage/rsx directory cleared on each request EXCEPT during bundle serving
*
* Production Mode (env('APP_ENV') === 'production'):
* ----------------------------------------------------------
* 1. Bundle filenames include manifest hash: app.(hash).js where hash = substr(sha256(manifest_hash . '|' . FQCN), 0, 32)
* - Changes when manifest OR bundle content changes
* 2. No query parameters needed (hash in filename handles cache-busting)
* 3. Bundles pre-compiled via artisan rsx:bundle:compile
* 4. Aggressive caching: "Cache-Control: public, max-age=31536000, immutable"
* 5. ETag headers for cache validation
*
* Test Scenarios:
* ---------------
* - Verify filename format validation (regex: /^app\.[a-f0-9]{32}\.(js|css)$/)
* - Test 404 responses for invalid bundle names
* - Verify correct MIME types (application/javascript, text/css)
* - Test that bundles regenerate in development when files change
* - Verify production bundles remain cached between requests
* - Test security headers (X-Content-Type-Options: nosniff)
* - Verify /_compiled/ path routing works correctly
* - Test that non-existent bundles trigger on-demand compilation in debug mode
* - Verify that compilation errors are logged but don't throw exceptions
*
* Integration Tests:
* ------------------
* - Test full page load with bundle URLs in HTML
* - Verify jQuery loads before other scripts (prioritization)
* - Test that window.rsxapp data is correctly formatted (pretty in dev, compact in prod)
* - Verify manifest hash changes trigger new bundle URLs in production
* - Test that development mode allows immediate visibility of JS/CSS changes
*/
class AssetHandler
{
/**
* Cache of discovered public directories
*
* @var array
*/
protected static $public_directories = [];
/**
* Allowed file extensions for security
*
* @var array
*/
protected static $allowed_extensions = [
'css', 'js', 'json', 'xml',
'jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'ico',
'woff', 'woff2', 'ttf', 'eot', 'otf',
'mp3', 'mp4', 'webm', 'ogg', 'wav',
'pdf', 'zip', 'txt', 'md',
'html', 'htm'
];
/**
* MIME type mappings
*
* @var array
*/
protected static $mime_types = [
'css' => 'text/css',
'js' => 'application/javascript',
'json' => 'application/json',
'xml' => 'application/xml',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
'ico' => 'image/x-icon',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'eot' => 'application/vnd.ms-fontobject',
'otf' => 'font/otf',
'mp3' => 'audio/mpeg',
'mp4' => 'video/mp4',
'webm' => 'video/webm',
'ogg' => 'video/ogg',
'wav' => 'audio/wav',
'pdf' => 'application/pdf',
'zip' => 'application/zip',
'txt' => 'text/plain',
'md' => 'text/markdown',
'html' => 'text/html',
'htm' => 'text/html'
];
/**
* Whether directories have been discovered
* @var bool
*/
protected static $directories_discovered = false;
/**
* Check if a path is an asset request
*
* @param string $path
* @return bool
*/
public static function is_asset_request($path)
{
// Check if this is a compiled bundle request
if (str_starts_with($path, '/_compiled/')) {
// Validate filename format: BundleName__(vendor|app).(8 chars).(js|css) or BundleName__app.(16 chars).(js|css)
$filename = substr($path, 11); // Remove '/_compiled/'
return preg_match('/^[A-Za-z0-9_]+__(vendor|app)\.[a-f0-9]{8}\.(js|css)$/', $filename) ||
preg_match('/^[A-Za-z0-9_]+__app\.[a-f0-9]{16}\.(js|css)$/', $filename);
}
// Check if path has a file extension
$extension = pathinfo($path, PATHINFO_EXTENSION);
if (empty($extension)) {
return false;
}
// Check if extension is allowed
return in_array(strtolower($extension), static::$allowed_extensions);
}
/**
* Serve an asset file
*
* @param string $path The requested asset path
* @param Request $request
* @return Response
* @throws NotFoundHttpException
*/
public static function serve($path, Request $request)
{
// Handle compiled bundle requests
if (str_starts_with($path, '/_compiled/')) {
return static::__serve_compiled_bundle($path, $request);
}
// Ensure directories are discovered
static::__ensure_directories_discovered();
// Sanitize path to prevent directory traversal
$path = static::__sanitize_path($path);
// Find the file in public directories
$file_path = static::__find_asset_file($path);
if (!$file_path) {
throw new NotFoundHttpException("Asset not found: {$path}");
}
// Check if file is within allowed directories
if (!static::__is_safe_path($file_path)) {
Log::warning('Attempted directory traversal', [
'requested' => $path,
'resolved' => $file_path
]);
throw new NotFoundHttpException("Asset not found: {$path}");
}
// Create binary file response
$response = new BinaryFileResponse($file_path);
// Set MIME type
$mime_type = static::__get_mime_type($file_path);
$response->headers->set('Content-Type', $mime_type);
// Check if request has cache-busting parameter
$has_cache_buster = $request->query->has('v');
// Set cache headers based on cache-busting presence
static::__set_cache_headers($response, $file_path, $has_cache_buster);
// Handle conditional requests for non-cache-busted URLs
if (!$has_cache_buster) {
// BinaryFileResponse automatically handles If-None-Match and If-Modified-Since
// and will return 304 Not Modified when appropriate
$response->isNotModified($request);
}
// Set additional security headers
static::__set_security_headers($response, $mime_type);
// Enable gzip if supported
if (static::__should_compress($mime_type)) {
$response->headers->set('Content-Encoding', 'gzip');
}
return $response;
}
/**
* Ensure directories are discovered (lazy initialization)
*/
protected static function __ensure_directories_discovered()
{
if (!static::$directories_discovered) {
static::__discover_public_directories();
static::$directories_discovered = true;
}
}
/**
* Discover public directories in RSX modules
*
* @return void
*/
protected static function __discover_public_directories()
{
$rsx_path = base_path('rsx');
if (!File::isDirectory($rsx_path)) {
return;
}
// Scan for directories with public subdirectories
$directories = File::directories($rsx_path);
foreach ($directories as $dir) {
$public_dir = $dir . '/public';
if (File::isDirectory($public_dir)) {
$module_name = basename($dir);
static::$public_directories[$module_name] = $public_dir;
Log::debug('Discovered RSX public directory', [
'module' => $module_name,
'path' => $public_dir
]);
}
}
// Also check for a root public directory
$root_public = $rsx_path . '/public';
if (File::isDirectory($root_public)) {
static::$public_directories['_root'] = $root_public;
}
}
/**
* Sanitize path to prevent directory traversal
*
* @param string $path
* @return string
*/
protected static function __sanitize_path($path)
{
// Remove any .. or ./ sequences
$path = str_replace(['../', '..\\', './', '.\\'], '', $path);
// Remove duplicate slashes
$path = preg_replace('#/+#', '/', $path);
// Remove leading slash
$path = ltrim($path, '/');
return $path;
}
/**
* Find asset file in public directories
*
* @param string $path
* @return string|null Full file path or null if not found
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
protected static function __find_asset_file($path)
{
// NEVER serve PHP files under any circumstances
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if ($extension === 'php') {
throw new HttpException(403, 'PHP files cannot be served as static assets');
}
// Try each public directory
foreach (static::$public_directories as $module => $directory) {
$full_path = $directory . '/' . $path;
if (File::exists($full_path) && File::isFile($full_path)) {
// Check exclusion rules before returning
if (static::__is_file_excluded($full_path, $path)) {
throw new HttpException(403, 'Access to this file is forbidden');
}
return $full_path;
}
}
// Check if path includes module prefix (e.g., "admin/css/style.css")
$parts = explode('/', $path, 2);
if (count($parts) === 2) {
$module = $parts[0];
$asset_path = $parts[1];
if (isset(static::$public_directories[$module])) {
$full_path = static::$public_directories[$module] . '/' . $asset_path;
if (File::exists($full_path) && File::isFile($full_path)) {
// Check exclusion rules before returning
if (static::__is_file_excluded($full_path, $asset_path)) {
throw new HttpException(403, 'Access to this file is forbidden');
}
return $full_path;
}
}
}
return null;
}
/**
* Check if a file should be excluded from serving
*
* @param string $full_path The full filesystem path to the file
* @param string $relative_path The relative path requested
* @return bool True if file should be excluded
*/
protected static function __is_file_excluded($full_path, $relative_path)
{
// Get global exclusion patterns from config
$global_patterns = config('rsx.public.ignore_patterns', []);
// Find the public directory this file belongs to
$public_dir = static::__find_public_directory($full_path);
if (!$public_dir) {
return false;
}
// Load public_ignore.json if it exists
$local_patterns = [];
$ignore_file = $public_dir . '/public_ignore.json';
if (File::exists($ignore_file)) {
$json = json_decode(File::get($ignore_file), true);
if (is_array($json)) {
$local_patterns = $json;
}
}
// Combine all patterns
$all_patterns = array_merge($global_patterns, $local_patterns);
// Check each pattern
foreach ($all_patterns as $pattern) {
if (static::__matches_gitignore_pattern($relative_path, $pattern)) {
return true;
}
}
return false;
}
/**
* Find which public directory a file belongs to
*
* @param string $full_path
* @return string|null
*/
protected static function __find_public_directory($full_path)
{
$real_path = realpath($full_path);
if (!$real_path) {
return null;
}
// Find the public directory that contains this file
foreach (static::$public_directories as $directory) {
$real_directory = realpath($directory);
if ($real_directory && str_starts_with($real_path, $real_directory)) {
return $real_directory;
}
}
return null;
}
/**
* Check if a path matches a gitignore-style pattern
*
* @param string $path The file path to check
* @param string $pattern The gitignore pattern
* @return bool
*/
protected static function __matches_gitignore_pattern($path, $pattern)
{
// Normalize path separators
$path = str_replace('\\', '/', $path);
$pattern = str_replace('\\', '/', $pattern);
// Handle negation (not implemented - we only block, never unblock)
if (str_starts_with($pattern, '!')) {
return false;
}
// Convert gitignore pattern to regex
$regex = static::__gitignore_to_regex($pattern);
return (bool) preg_match($regex, $path);
}
/**
* Convert gitignore pattern to regex
*
* @param string $pattern
* @return string
*/
protected static function __gitignore_to_regex($pattern)
{
// Remove leading/trailing whitespace
$pattern = trim($pattern);
// Escape special regex characters except * and ?
$pattern = preg_quote($pattern, '#');
// Restore * and ? for conversion
$pattern = str_replace(['\*', '\?'], ['STAR', 'QUESTION'], $pattern);
// Convert gitignore wildcards to regex
$pattern = str_replace('STAR', '.*', $pattern); // * matches anything
$pattern = str_replace('QUESTION', '.', $pattern); // ? matches single char
// If pattern ends with /, it matches directories (any file within)
if (str_ends_with($pattern, '/')) {
$pattern = rtrim($pattern, '/');
$pattern = $pattern . '(?:/.*)?';
}
// If pattern doesn't start with /, it can match at any level
if (!str_starts_with($pattern, '/')) {
$pattern = '(?:.*/)?' . $pattern;
} else {
$pattern = ltrim($pattern, '/');
}
// Anchor pattern
return '#^' . $pattern . '$#';
}
/**
* Check if path is safe (within allowed directories)
*
* @param string $path
* @return bool
*/
protected static function __is_safe_path($path)
{
$real_path = realpath($path);
if ($real_path === false) {
return false;
}
// Check if real path is within any allowed directory
foreach (static::$public_directories as $directory) {
$real_directory = realpath($directory);
if (str_starts_with($real_path, $real_directory)) {
return true;
}
}
return false;
}
/**
* Get MIME type for file
*
* @param string $file_path
* @return string
*/
protected static function __get_mime_type($file_path)
{
$extension = strtolower(pathinfo($file_path, PATHINFO_EXTENSION));
if (isset(static::$mime_types[$extension])) {
return static::$mime_types[$extension];
}
// Try to detect MIME type
$mime = mime_content_type($file_path);
return $mime ?: 'application/octet-stream';
}
/**
* Set cache headers for response
*
* @param BinaryFileResponse $response
* @param string $file_path
* @param bool $has_cache_buster Whether request has ?v= cache-busting parameter
* @return void
*/
protected static function __set_cache_headers(BinaryFileResponse $response, $file_path, $has_cache_buster)
{
if ($has_cache_buster) {
// Aggressive caching - URL has version parameter
$cache_seconds = 2592000; // 30 days (1 month)
$response->setMaxAge($cache_seconds);
$response->setPublic();
$response->headers->set('Expires', gmdate('D, d M Y H:i:s', time() + $cache_seconds) . ' GMT');
$response->headers->set('Cache-Control', 'public, max-age=' . $cache_seconds . ', immutable');
// No revalidation headers needed - file is cache-busted via ?v= parameter
} else {
// Conservative caching with revalidation - no version parameter
$cache_seconds = 300; // 5 minutes
$response->setMaxAge($cache_seconds);
$response->setPublic();
$response->headers->set('Cache-Control', 'public, max-age=' . $cache_seconds . ', must-revalidate');
// Set ETag and Last-Modified for efficient revalidation
// BinaryFileResponse automatically sets these based on file mtime and size
$response->setAutoEtag();
$response->setAutoLastModified();
}
}
/**
* Set security headers
*
* @param BinaryFileResponse $response
* @param string $mime_type
* @return void
*/
protected static function __set_security_headers(BinaryFileResponse $response, $mime_type)
{
// Prevent MIME type sniffing
$response->headers->set('X-Content-Type-Options', 'nosniff');
// Set CSP for HTML files
if (str_starts_with($mime_type, 'text/html')) {
$response->headers->set('Content-Security-Policy', "default-src 'self'");
}
// Prevent framing for HTML
if (str_starts_with($mime_type, 'text/html')) {
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
}
}
/**
* Serve a compiled bundle file
*
* @param string $path The requested path (e.g., /_compiled/Demo_Bundle__vendor.abc12345.js)
* @param Request $request
* @return Response
* @throws NotFoundHttpException
*/
protected static function __serve_compiled_bundle($path, Request $request)
{
// Extract filename from path
$filename = substr($path, 11); // Remove '/_compiled/'
// Validate filename format: BundleName__(vendor|app).(8 chars).(js|css) or BundleName__app.(16 chars).(js|css)
if (!preg_match('/^([A-Za-z0-9_]+)__(vendor|app)\.([a-f0-9]{8}|[a-f0-9]{16})\.(js|css)$/', $filename, $matches)) {
throw new NotFoundHttpException("Invalid bundle filename: {$filename}");
}
$bundle_name = $matches[1];
$type = $matches[2];
$hash = $matches[3];
$extension = $matches[4];
// Build full path to file
$file_path = storage_path("rsx-build/bundles/{$filename}");
// In development mode, compile bundle on-the-fly if it doesn't exist
if (env('APP_ENV') !== 'production' && !file_exists($file_path)) {
// Try to compile the bundle on-demand
static::__compile_bundle_on_demand($bundle_name, $type, $hash, $extension);
}
// Check if file exists
if (!file_exists($file_path)) {
throw new NotFoundHttpException("Bundle not found: {$filename}");
}
// Create binary file response
$response = new BinaryFileResponse($file_path);
// Set appropriate content type
$extension = pathinfo($filename, PATHINFO_EXTENSION);
$mime_type = $extension === 'js' ? 'application/javascript' : 'text/css';
$response->headers->set('Content-Type', $mime_type . '; charset=utf-8');
// Set cache headers - 14 day cache for all compiled bundles (same for dev and prod)
// Since we use cache-busting in filenames/query strings, we can cache aggressively
$cache_seconds = 1209600; // 14 days
$response->headers->set('Cache-Control', 'public, max-age=' . $cache_seconds . ', immutable');
$response->headers->set('Expires', gmdate('D, d M Y H:i:s', time() + $cache_seconds) . ' GMT');
// No ETag or revalidation needed - files are cache-busted via filename/query string
// Security headers
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
/**
* Compile a bundle on-demand in development mode
*
* @param string $bundle_name The bundle name from the requested filename
* @param string $type The bundle type (vendor or app)
* @param string $hash The hash from the requested filename
* @param string $extension The file extension (js or css)
* @return void
*/
protected static function __compile_bundle_on_demand($bundle_name, $type, $hash, $extension)
{
// Find the bundle class matching the bundle name
try {
// Try to find by simple class name
$metadata = \App\RSpade\Core\Manifest\Manifest::php_get_metadata_by_class($bundle_name);
if (!isset($metadata['fqcn'])) {
return;
}
$fqcn = $metadata['fqcn'];
// Compile the bundle
try {
$compiler = new \App\RSpade\Core\Bundle\BundleCompiler();
$compiler->compile($fqcn);
return;
} catch (\Exception $e) {
// Log error but don't throw - let the file not found error happen
\Illuminate\Support\Facades\Log::error("Failed to compile bundle on-demand: {$fqcn}", [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
}
} catch (\RuntimeException $e) {
// Bundle class not found in manifest
return;
}
}
/**
* Check if content should be compressed
*
* @param string $mime_type
* @return bool
*/
protected static function __should_compress($mime_type)
{
// Compress text-based content
$compressible = [
'text/',
'application/javascript',
'application/json',
'application/xml',
'image/svg+xml'
];
foreach ($compressible as $type) {
if (str_starts_with($mime_type, $type)) {
return true;
}
}
return false;
}
/**
* Get discovered public directories
*
* @return array
*/
public static function get_public_directories()
{
static::__ensure_directories_discovered();
return static::$public_directories;
}
/**
* Clear and rediscover public directories
*
* @return void
*/
public static function refresh_directories()
{
static::$public_directories = [];
static::$directories_discovered = false;
static::__discover_public_directories();
static::$directories_discovered = true;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,366 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Dispatch;
use ReflectionClass;
use RuntimeException;
use Throwable;
use App\RSpade\Core\Controller\Rsx_Controller_Abstract;
use App\RSpade\Core\Manifest\Manifest;
/**
* HandlerFactory determines handler types and creates appropriate response builders
*
* Handler types:
* - controller: HTML responses (extends Rsx_Controller_Abstract)
* - file: File operations (extends RSpade_FileHandler)
* - asset: Static assets from /rsx/[name]/public/ directories
* - custom: User-registered handlers
*/
class HandlerFactory
{
/**
* Handler type constants
*/
public const TYPE_CONTROLLER = 'controller';
public const TYPE_FILE = 'file';
public const TYPE_ASSET = 'asset';
public const TYPE_CUSTOM = 'custom';
public const TYPE_UNKNOWN = 'unknown';
/**
* Custom handler registry
* @var array
*/
protected static $custom_handlers = [];
/**
* Response builder instances
* @var array
*/
protected static $response_builders = [];
/**
* Detect handler type from class name
*
* @param string $class_name
* @return string Handler type constant
*/
public static function detect_handler_type($class_name)
{
// Check custom handlers first
if (isset(self::$custom_handlers[$class_name])) {
return self::TYPE_CUSTOM;
}
// Check if class exists in Manifest
try {
$metadata = Manifest::php_get_metadata_by_class($class_name);
$class_name = $metadata['fqcn'];
} catch (RuntimeException $e) {
// Try with Rsx namespace
try {
$metadata = Manifest::php_get_metadata_by_fqcn('Rsx\\' . $class_name);
$class_name = $metadata['fqcn'];
} catch (RuntimeException $e2) {
return self::TYPE_UNKNOWN;
}
}
try {
$reflection = new ReflectionClass($class_name);
// Check inheritance chain
if (static::__extends_class($reflection, RSpade_FileHandler::class)) {
return self::TYPE_FILE;
}
if (static::__extends_class($reflection, Rsx_Controller_Abstract::class)) {
return self::TYPE_CONTROLLER;
}
// Check custom handler interfaces if registered
foreach (self::$custom_handlers as $interface => $type) {
if ($reflection->implementsInterface($interface)) {
return self::TYPE_CUSTOM;
}
}
return self::TYPE_UNKNOWN;
} catch (Throwable $e) {
return self::TYPE_UNKNOWN;
}
}
/**
* Check if a class extends another class
*
* @param ReflectionClass $reflection
* @param string $base_class
* @return bool
*/
protected static function __extends_class(ReflectionClass $reflection, $base_class)
{
while ($parent = $reflection->getParentClass()) {
if ($parent->getName() === $base_class) {
return true;
}
$reflection = $parent;
}
return false;
}
/**
* Get response builder for handler type
*
* @param string $handler_type
* @return ResponseBuilder|null
*/
public static function get_response_builder($handler_type)
{
// Cache response builders
if (!isset(self::$response_builders[$handler_type])) {
self::$response_builders[$handler_type] = static::__create_response_builder($handler_type);
}
return self::$response_builders[$handler_type];
}
/**
* Create response builder for handler type
*
* @param string $handler_type
* @return ResponseBuilder|null
*/
protected static function __create_response_builder($handler_type)
{
// In the next phase (2.4), we'll implement specific response builders
// For now, return null - the Dispatcher will handle responses directly
return null;
}
/**
* Register a custom handler type
*
* @param string $class_or_interface Class name or interface
* @param string $handler_type Type identifier
* @param callable|null $response_builder Optional response builder
*/
public static function register_custom_handler($class_or_interface, $handler_type, $response_builder = null)
{
self::$custom_handlers[$class_or_interface] = $handler_type;
if ($response_builder !== null) {
self::$response_builders[$handler_type] = $response_builder;
}
}
/**
* Unregister a custom handler
*
* @param string $class_or_interface
*/
public static function unregister_custom_handler($class_or_interface)
{
unset(self::$custom_handlers[$class_or_interface]);
}
/**
* Get all registered custom handlers
*
* @return array
*/
public static function get_custom_handlers()
{
return self::$custom_handlers;
}
/**
* Clear all custom handlers
*/
public static function clear_custom_handlers()
{
self::$custom_handlers = [];
self::$response_builders = [];
}
/**
* Determine if a path is an asset request
*
* @param string $path
* @return bool
*/
public static function is_asset_request($path)
{
// Check if path matches asset pattern
// Assets are in /rsx/*/public/ directories
if (preg_match('#^/rsx/[^/]+/public/#', $path)) {
return true;
}
// Check common asset extensions
$asset_extensions = ['js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'woff', 'woff2', 'ttf', 'eot'];
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (in_array($extension, $asset_extensions)) {
// Check if it's in a known asset directory
if (preg_match('#/(assets|public|static|dist|build)/#', $path)) {
return true;
}
}
return false;
}
/**
* Get handler metadata
*
* @param string $class_name
* @return array
*/
public static function get_handler_metadata($class_name)
{
$type = static::detect_handler_type($class_name);
$metadata = [
'class' => $class_name,
'type' => $type,
'base_class' => null,
'interfaces' => [],
'traits' => [],
];
if ($type === self::TYPE_UNKNOWN) {
return $metadata;
}
try {
$reflection = new ReflectionClass($class_name);
// Get base class
$parent = $reflection->getParentClass();
if ($parent) {
$metadata['base_class'] = $parent->getName();
}
// Get interfaces
$metadata['interfaces'] = $reflection->getInterfaceNames();
// Get traits
$metadata['traits'] = $reflection->getTraitNames();
} catch (Throwable $e) {
// Keep default metadata
}
return $metadata;
}
/**
* Validate handler class
*
* @param string $class_name
* @param string $method_name
* @return array ['valid' => bool, 'errors' => array]
*/
public static function validate_handler($class_name, $method_name)
{
$errors = [];
// Check if class exists in Manifest
try {
$metadata = Manifest::php_get_metadata_by_class($class_name);
// Class exists in manifest
} catch (RuntimeException $e) {
// Also try as FQCN
try {
$metadata = Manifest::php_get_metadata_by_fqcn($class_name);
} catch (RuntimeException $e2) {
$errors[] = "Handler class not found in manifest: {$class_name}";
return ['valid' => false, 'errors' => $errors];
}
}
try {
$reflection = new ReflectionClass($class_name);
// Check if method exists
if (!$reflection->hasMethod($method_name)) {
$errors[] = "Handler method not found: {$class_name}::{$method_name}";
}
// Check if method is public and static
if ($reflection->hasMethod($method_name)) {
$method = $reflection->getMethod($method_name);
if (!$method->isPublic()) {
$errors[] = "Handler method must be public: {$class_name}::{$method_name}";
}
if (!$method->isStatic()) {
$errors[] = "Handler method must be static: {$class_name}::{$method_name}";
}
}
// Check handler type
$type = static::detect_handler_type($class_name);
if ($type === self::TYPE_UNKNOWN) {
$errors[] = "Unknown handler type for class: {$class_name}";
}
} catch (Throwable $e) {
$errors[] = 'Error validating handler: ' . $e->getMessage();
}
return [
'valid' => empty($errors),
'errors' => $errors,
];
}
/**
* Get priority for handler type
*
* @param string $handler_type
* @return int Lower number = higher priority
*/
public static function get_handler_priority($handler_type)
{
$priorities = [
self::TYPE_CONTROLLER => 1,
self::TYPE_FILE => 2,
self::TYPE_ASSET => 3,
self::TYPE_CUSTOM => 4,
self::TYPE_UNKNOWN => 99,
];
return $priorities[$handler_type] ?? 99;
}
/**
* Sort handlers by priority
*
* @param array $handlers Array of ['class' => string, 'type' => string, ...]
* @return array Sorted handlers
*/
public static function sort_handlers_by_priority($handlers)
{
usort($handlers, function ($a, $b) {
$priority_a = static::get_handler_priority($a['type'] ?? self::TYPE_UNKNOWN);
$priority_b = static::get_handler_priority($b['type'] ?? self::TYPE_UNKNOWN);
return $priority_a - $priority_b;
});
return $handlers;
}
}

View File

@@ -0,0 +1,476 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Dispatch;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
use App\RSpade\Core\Dispatch\HandlerFactory;
/**
* ResponseBuilder builds appropriate HTTP responses based on handler results
*
* Supports response types:
* - view: Render Laravel view
* - json: JSON response
* - redirect: HTTP redirect
* - file: File download/stream
* - stream: Streamed response
* - error: Error response
* - empty: Empty response
* - raw: Raw text/html response
*/
class ResponseBuilder
{
/**
* Response type constants
*/
const TYPE_VIEW = 'view';
const TYPE_JSON = 'json';
const TYPE_REDIRECT = 'redirect';
const TYPE_FILE = 'file';
const TYPE_STREAM = 'stream';
const TYPE_ERROR = 'error';
const TYPE_EMPTY = 'empty';
const TYPE_RAW = 'raw';
/**
* Default response headers by type
* @var array
*/
protected static $default_headers = [
self::TYPE_JSON => ['Content-Type' => 'application/json'],
self::TYPE_RAW => ['Content-Type' => 'text/html; charset=UTF-8'],
];
/**
* Build response from handler result
*
* @param mixed $result Handler result
* @param string $handler_type Handler type from HandlerFactory
* @param array $attributes Method attributes
* @return Response
*/
public static function build($result, $handler_type = null, $attributes = [])
{
// If already a Response object, return as-is
if ($result instanceof Response || $result instanceof JsonResponse ||
$result instanceof RedirectResponse || $result instanceof BinaryFileResponse ||
$result instanceof StreamedResponse) {
return $result;
}
// Handle array responses with type hints
if (is_array($result) && isset($result['type'])) {
return static::__build_typed_response($result);
}
// Handle based on handler type
if ($handler_type !== null) {
return static::__build_by_handler_type($result, $handler_type, $attributes);
}
// Default: return as JSON
return static::__build_json_response($result);
}
/**
* Build response from typed result array
*
* @param array $result
* @return Response
*/
protected static function __build_typed_response($result)
{
$type = $result['type'];
switch ($type) {
case self::TYPE_VIEW:
return static::__build_view_response($result);
case self::TYPE_JSON:
return static::__build_json_response(
$result['data'] ?? $result,
$result['status'] ?? 200,
$result['headers'] ?? []
);
case self::TYPE_REDIRECT:
return static::__build_redirect_response($result);
case self::TYPE_FILE:
return static::__build_file_response($result);
case self::TYPE_STREAM:
return static::__build_stream_response($result);
case self::TYPE_ERROR:
return static::__build_error_response($result);
case self::TYPE_EMPTY:
return static::__build_empty_response(
$result['status'] ?? 204,
$result['headers'] ?? []
);
case self::TYPE_RAW:
return static::__build_raw_response($result);
default:
// Unknown type, return as JSON
return static::__build_json_response($result);
}
}
/**
* Build response based on handler type
*
* @param mixed $result
* @param string $handler_type
* @param array $attributes
* @return Response
*/
protected static function __build_by_handler_type($result, $handler_type, $attributes)
{
switch ($handler_type) {
case HandlerFactory::TYPE_CONTROLLER:
// Controllers default to view responses
if (is_array($result) && !isset($result['type'])) {
$result['type'] = self::TYPE_VIEW;
return static::__build_typed_response($result);
}
break;
case HandlerFactory::TYPE_API:
// API handlers default to JSON
if (!is_array($result) || !isset($result['type'])) {
return static::__build_json_response($result);
}
break;
case HandlerFactory::TYPE_FILE:
// File handlers default to file responses
if (is_array($result) && !isset($result['type'])) {
$result['type'] = self::TYPE_FILE;
return static::__build_typed_response($result);
}
break;
}
// If we have a typed response, build it
if (is_array($result) && isset($result['type'])) {
return static::__build_typed_response($result);
}
// Default to JSON
return static::__build_json_response($result);
}
/**
* Build view response
*
* @param array $result
* @return Response
*/
protected static function __build_view_response($result)
{
$view = $result['view'] ?? 'welcome';
$data = $result['data'] ?? [];
$status = $result['status'] ?? 200;
$headers = $result['headers'] ?? [];
// Check if view exists
if (!View::exists($view)) {
// Return error response if view not found
return static::__build_error_response([
'code' => 500,
'message' => "View not found: {$view}"
]);
}
return response()->view($view, $data, $status)->withHeaders($headers);
}
/**
* Build JSON response
*
* @param mixed $data
* @param int $status
* @param array $headers
* @return JsonResponse
*/
protected static function __build_json_response($data, $status = 200, $headers = [])
{
// Merge with default JSON headers
$headers = array_merge(static::$default_headers[self::TYPE_JSON], $headers);
return response()->json($data, $status, $headers);
}
/**
* Build redirect response
*
* @param array $result
* @return Response
*/
protected static function __build_redirect_response($result)
{
$url = $result['url'] ?? '/';
$status = $result['status'] ?? 302;
$headers = $result['headers'] ?? [];
$secure = $result['secure'] ?? null;
// Use Laravel's redirect helper for proper URL generation
$response = redirect($url, $status, $headers, $secure);
// Add any additional headers
if (!empty($result['with'])) {
foreach ($result['with'] as $key => $value) {
$response->with($key, $value);
}
}
return $response;
}
/**
* Build file response
*
* @param array $result
* @return BinaryFileResponse|Response
*/
protected static function __build_file_response($result)
{
$path = $result['path'] ?? null;
if (!$path || !file_exists($path)) {
return static::__build_error_response([
'code' => 404,
'message' => 'File not found'
]);
}
$headers = $result['headers'] ?? [];
// Create file response
$response = response()->file($path, $headers);
// Set download name if provided
if (isset($result['name'])) {
$disposition = $result['disposition'] ?? 'attachment';
$response->headers->set(
'Content-Disposition',
"{$disposition}; filename=\"" . $result['name'] . "\""
);
}
// Set MIME type if provided
if (isset($result['mime'])) {
$response->headers->set('Content-Type', $result['mime']);
}
return $response;
}
/**
* Build stream response
*
* @param array $result
* @return StreamedResponse
*/
protected static function __build_stream_response($result)
{
$callback = $result['callback'] ?? function() { echo ''; };
$status = $result['status'] ?? 200;
$headers = $result['headers'] ?? [];
return response()->stream($callback, $status, $headers);
}
/**
* Build error response
*
* @param array $result
* @return Response
*/
protected static function __build_error_response($result)
{
$code = $result['code'] ?? 500;
$message = $result['message'] ?? 'Server Error';
$headers = $result['headers'] ?? [];
// Use Laravel's abort for proper error handling
abort($code, $message);
}
/**
* Build empty response
*
* @param int $status
* @param array $headers
* @return Response
*/
protected static function __build_empty_response($status = 204, $headers = [])
{
return response('', $status, $headers);
}
/**
* Build raw response
*
* @param array $result
* @return Response
*/
protected static function __build_raw_response($result)
{
$content = $result['content'] ?? '';
$status = $result['status'] ?? 200;
$headers = array_merge(
static::$default_headers[self::TYPE_RAW],
$result['headers'] ?? []
);
return response($content, $status, $headers);
}
/**
* Set CORS headers for response
*
* @param Response $response
* @param array $cors_config
* @return Response
*/
public static function set_cors_headers($response, $cors_config = [])
{
$default_cors = [
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-Requested-With',
'Access-Control-Max-Age' => '86400',
];
$cors = array_merge($default_cors, $cors_config);
foreach ($cors as $header => $value) {
$response->headers->set($header, $value);
}
return $response;
}
/**
* Set cache headers for response
*
* @param Response $response
* @param int $ttl Time to live in seconds
* @param array $options Additional cache options
* @return Response
*/
public static function set_cache_headers($response, $ttl = 3600, $options = [])
{
if ($ttl > 0) {
$response->headers->set('Cache-Control', "public, max-age={$ttl}");
// Set expires header
$expires = gmdate('D, d M Y H:i:s', time() + $ttl) . ' GMT';
$response->headers->set('Expires', $expires);
// Set ETag if provided
if (isset($options['etag'])) {
$response->headers->set('ETag', $options['etag']);
}
// Set Last-Modified if provided
if (isset($options['last_modified'])) {
$response->headers->set('Last-Modified', $options['last_modified']);
}
} else {
// No cache
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', '0');
}
return $response;
}
/**
* Apply response attributes
*
* @param Response $response
* @param array $attributes
* @return Response
*/
public static function apply_attributes($response, $attributes)
{
// Apply CORS if specified
if (isset($attributes['cors'])) {
static::set_cors_headers($response, $attributes['cors']);
}
// Apply cache if specified
if (isset($attributes['cache'])) {
$cache_config = $attributes['cache'];
$ttl = $cache_config['ttl'] ?? 3600;
static::set_cache_headers($response, $ttl, $cache_config);
}
// Apply custom headers
if (isset($attributes['headers'])) {
foreach ($attributes['headers'] as $header => $value) {
$response->headers->set($header, $value);
}
}
return $response;
}
/**
* Build response for asset file
*
* @param string $path
* @param string $base_path
* @return Response
*/
public static function build_asset_response($path, $base_path = null)
{
// Resolve full path
if ($base_path) {
$full_path = rtrim($base_path, '/') . '/' . ltrim($path, '/');
} else {
$full_path = public_path($path);
}
if (!file_exists($full_path)) {
return static::__build_error_response([
'code' => 404,
'message' => 'Asset not found'
]);
}
// Determine MIME type
$mime = mime_content_type($full_path);
// Build response
$response = response()->file($full_path, [
'Content-Type' => $mime
]);
// Set cache headers for assets (1 year for production)
if (app()->environment('production')) {
static::set_cache_headers($response, 31536000); // 1 year
} else {
static::set_cache_headers($response, 3600); // 1 hour for dev
}
return $response;
}
}

View File

@@ -0,0 +1,430 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Dispatch;
use App\RSpade\Core\Build_Manager;
/**
* RouteResolver handles route pattern matching and parameter extraction
*
* Supports patterns:
* - /exact/match - Exact URL match
* - /users/:id - Named parameter
* - /posts/:year/:month - Multiple parameters
* - /api/* - Wildcard
* - /files/:id.:ext - Mixed patterns
* - /optional/:id? - Optional parameters
*/
class RouteResolver
{
/**
* Cache of compiled patterns
* @var array
*/
protected static $pattern_cache = [];
/**
* Cache key prefix for file-based caching
*/
const CACHE_PREFIX = 'route_patterns/';
/**
* Match a URL against a route pattern
*
* @param string $url The URL to match (without query string)
* @param string $pattern The route pattern
* @return array|false Array with params on match, false otherwise
*/
public static function match($url, $pattern)
{
// Normalize URL and pattern
$url = static::__normalize_url($url);
$pattern = static::__normalize_pattern($pattern);
// Get compiled regex and parameter names
$compiled = static::compile_pattern($pattern);
if (!$compiled) {
return false;
}
// Match URL against regex
if (preg_match($compiled['regex'], $url, $matches)) {
// Extract parameters
$params = static::__extract_params($matches, $compiled['params']);
return $params;
}
return false;
}
/**
* Match URL with query string handling
*
* @param string $full_url Full URL including query string
* @param string $pattern Route pattern
* @return array|false
*/
public static function match_with_query($full_url, $pattern)
{
// Split URL and query string
$url_parts = parse_url($full_url);
$path = $url_parts['path'] ?? '/';
// Match the path
$params = static::match($path, $pattern);
if ($params === false) {
return false;
}
// Parse and merge query string parameters
// URL route parameters take precedence over GET parameters
if (isset($url_parts['query'])) {
parse_str($url_parts['query'], $query_params);
$params = array_merge($query_params, $params);
}
return $params;
}
/**
* Compile a route pattern into regex
*
* @param string $pattern The route pattern
* @return array|null Array with 'regex' and 'params' keys
*/
public static function compile_pattern($pattern)
{
// Check memory cache
if (isset(self::$pattern_cache[$pattern])) {
return self::$pattern_cache[$pattern];
}
// Check file cache
$cache_key = self::CACHE_PREFIX . md5($pattern);
$cached = Build_Manager::get($cache_key, 'cache');
if ($cached !== null && is_string($cached)) {
$cached = json_decode($cached, true);
if ($cached !== null) {
self::$pattern_cache[$pattern] = $cached;
return $cached;
}
}
// Extract parameter names
$param_names = [];
// First, handle our special syntax before escaping
// Replace :param and :param? with placeholders
$placeholder_index = 0;
$placeholders = [];
$temp_pattern = preg_replace_callback(
'/\/:([\\w]+)(\\?)?|:([\\w]+)(\\?)?/',
function ($matches) use (&$param_names, &$placeholder_index, &$placeholders) {
// Check if we matched with leading slash or without
$has_slash = str_starts_with($matches[0], '/');
if ($has_slash) {
// Matched /:param or /:param?
$param_name = $matches[1];
$is_optional = !empty($matches[2]);
} else {
// Matched :param or :param?
$param_name = $matches[3];
$is_optional = !empty($matches[4]);
}
$param_names[] = $param_name;
$placeholder = "__PARAM_{$placeholder_index}__";
if ($is_optional) {
// Optional parameter - the slash and parameter are both optional
// For /posts/:id? we want to match both /posts and /posts/123
$placeholders[$placeholder] = $has_slash ? '(?:/([^/]+))?' : '([^/]*)';
} else {
// Required parameter
$placeholders[$placeholder] = $has_slash ? '/([^/]+)' : '([^/]+)';
}
$placeholder_index++;
return $placeholder;
},
$pattern,
);
// Replace wildcards with placeholders
$wildcard_index = 0;
$temp_pattern = preg_replace_callback(
'/\*/',
function ($matches) use (&$param_names, &$wildcard_index, &$placeholders) {
$param_names[] = 'wildcard' . ($wildcard_index > 0 ? $wildcard_index : '');
$placeholder = "__WILDCARD_{$wildcard_index}__";
$placeholders[$placeholder] = '(.*)';
$wildcard_index++;
return $placeholder;
},
$temp_pattern,
);
// Now escape regex characters
$regex = preg_quote($temp_pattern, '#');
// Replace placeholders with regex patterns
foreach ($placeholders as $placeholder => $regex_pattern) {
$regex = str_replace($placeholder, $regex_pattern, $regex);
}
// Wrap in delimiters
$regex = '#^' . $regex . '$#';
$compiled = [
'regex' => $regex,
'params' => $param_names,
'pattern' => $pattern,
];
// Cache the compiled pattern
self::$pattern_cache[$pattern] = $compiled;
Build_Manager::put($cache_key, json_encode($compiled), 'cache');
return $compiled;
}
/**
* Extract parameters from regex matches
*
* @param array $matches Regex matches
* @param array $param_names Parameter names
* @return array Extracted parameters
*/
protected static function __extract_params($matches, $param_names)
{
$params = [];
// Skip first match (full string)
array_shift($matches);
// Map matches to parameter names
foreach ($param_names as $index => $name) {
if (isset($matches[$index])) {
$value = $matches[$index];
// URL decode the value
$value = urldecode($value);
// Don't include empty optional parameters
if ($value !== '') {
$params[$name] = $value;
}
}
}
return $params;
}
/**
* Normalize a URL for matching
*
* @param string $url
* @return string
*/
protected static function __normalize_url($url)
{
// Remove query string
$url = strtok($url, '?');
// Ensure leading slash
if (!str_starts_with($url, '/')) {
$url = '/' . $url;
}
// Remove trailing slash (except for root)
if ($url !== '/' && str_ends_with($url, '/')) {
$url = rtrim($url, '/');
}
return $url;
}
/**
* Normalize a route pattern
*
* @param string $pattern
* @return string
*/
protected static function __normalize_pattern($pattern)
{
// Ensure leading slash
if (!str_starts_with($pattern, '/')) {
$pattern = '/' . $pattern;
}
// Remove trailing slash (except for root)
if ($pattern !== '/' && str_ends_with($pattern, '/')) {
$pattern = rtrim($pattern, '/');
}
return $pattern;
}
/**
* Check if a URL matches any of the given patterns
*
* @param string $url URL to check
* @param array $patterns Array of patterns to match against
* @return array|false First matching result or false
*/
public static function match_any($url, $patterns)
{
foreach ($patterns as $pattern) {
$result = static::match($url, $pattern);
if ($result !== false) {
return ['pattern' => $pattern, 'params' => $result];
}
}
return false;
}
/**
* Clear pattern cache
*
* @param string|null $pattern Specific pattern to clear, or null for all
*/
public static function clear_cache($pattern = null)
{
if ($pattern !== null) {
unset(self::$pattern_cache[$pattern]);
// We can't delete individual files easily with BuildManager
// Just clear from memory cache
} else {
self::$pattern_cache = [];
// Clear all cached patterns by clearing the cache directory
// This is a bit aggressive but BuildManager doesn't have selective deletion
// In production, patterns are stable so this shouldn't be called often
}
}
/**
* Generate a URL from a pattern and parameters
*
* @param string $pattern Route pattern
* @param array $params Parameters to fill in
* @return string|null Generated URL or null if params don't match
*/
public static function generate($pattern, $params = [])
{
$url = $pattern;
// Replace named parameters
$url = preg_replace_callback(
'/:([\w]+)(\?)?/',
function ($matches) use (&$params) {
$param_name = $matches[1];
$is_optional = isset($matches[2]);
if (isset($params[$param_name])) {
$value = $params[$param_name];
unset($params[$param_name]);
return urlencode($value);
} elseif ($is_optional) {
return '';
} else {
// Required parameter missing
return ':' . $param_name;
}
},
$url,
);
// Check if all required params were replaced
if (strpos($url, ':') !== false) {
return null; // Still has unreplaced parameters
}
// Replace wildcards
if (strpos($url, '*') !== false) {
// Use 'wildcard' param if available
if (isset($params['wildcard'])) {
$url = str_replace('*', $params['wildcard'], $url);
unset($params['wildcard']);
} else {
$url = str_replace('*', '', $url);
}
}
// Normalize the URL first (before adding query string)
$url = static::__normalize_url($url);
// Add remaining params as query string
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
return $url;
}
/**
* Get priority score for a pattern (more specific = higher priority)
*
* @param string $pattern
* @return int Priority score
*/
public static function get_pattern_priority($pattern)
{
$score = 0;
// Exact matches have highest priority
if (!str_contains($pattern, ':') && !str_contains($pattern, '*')) {
$score += 1000;
}
// Count segments
$segments = explode('/', trim($pattern, '/'));
$score += count($segments) * 100;
// Penalize wildcards
$score -= substr_count($pattern, '*') * 50;
// Penalize optional parameters
$score -= substr_count($pattern, '?') * 10;
// Penalize required parameters
$score -= preg_match_all('/:([\w]+)(?!\?)/', $pattern) * 5;
return $score;
}
/**
* Sort patterns by priority (higher priority first)
*
* @param array $patterns
* @return array Sorted patterns
*/
public static function sort_by_priority($patterns)
{
$scored = [];
foreach ($patterns as $pattern) {
$scored[] = [
'pattern' => $pattern,
'score' => static::get_pattern_priority($pattern),
];
}
// Sort by score descending
usort($scored, function ($a, $b) {
return $b['score'] - $a['score'];
});
// Extract sorted patterns
return array_column($scored, 'pattern');
}
}

View File

@@ -0,0 +1,92 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Exceptions;
use Illuminate\Http\Request;
use Throwable;
use App\RSpade\Core\Debug\Debugger;
use App\RSpade\Core\Dispatch\Ajax_Endpoint_Controller;
use App\RSpade\Core\Exceptions\Rsx_Exception_Handler_Abstract;
/**
* Ajax_ExceptionHandler - Handle exceptions during AJAX endpoint execution
*
* PRIORITY: 20
*
* This handler processes exceptions that occur during AJAX endpoint execution
* (when using #[Ajax_Endpoint] attribute). It returns JSON-formatted errors with:
* - Error message
* - File and line number
* - Stack trace (last 10 frames)
* - Console debug messages (if any)
*
* This ensures AJAX errors are returned as JSON instead of HTML error pages.
*/
class Ajax_Exception_Handler extends Rsx_Exception_Handler_Abstract
{
/**
* Get priority - AJAX handlers run after CLI but before web
*
* @return int
*/
public static function get_priority(): int
{
return 20;
}
/**
* Handle exception if in AJAX response mode
*
* @param Throwable $e
* @param Request $request
* @return mixed JSON response if in AJAX mode, null otherwise
*/
public function handle(Throwable $e, Request $request)
{
// Only handle if we're in AJAX response mode
if (!Ajax_Endpoint_Controller::is_ajax_response_mode()) {
return null;
}
// Build error response
$error_data = [
'file' => str_replace(base_path() . '/', '', $e->getFile()),
'line' => $e->getLine(),
'error' => $e->getMessage(),
'backtrace' => [],
];
// Get backtrace without args
$trace = $e->getTrace();
foreach ($trace as $index => $frame) {
if ($index >= 10) {
break;
} // Limit to 10 frames
$error_data['backtrace'][] = [
'file' => isset($frame['file']) ? str_replace(base_path() . '/', '', $frame['file']) : 'unknown',
'line' => $frame['line'] ?? 0,
'function' => $frame['function'] ?? 'unknown',
'class' => $frame['class'] ?? null,
'type' => $frame['type'] ?? null,
];
}
// Build response
$response = ['error' => $error_data];
// Include console debug messages if any
$console_messages = Debugger::_get_console_messages();
if (!empty($console_messages)) {
$response['console_debug'] = $console_messages;
}
// Return JSON error response
return response()->json($response, 500);
}
}

View File

@@ -0,0 +1,177 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Exceptions;
use Illuminate\Http\Request;
use Throwable;
use App\RSpade\Core\Exceptions\Rsx_Exception_Handler_Abstract;
/**
* Cli_ExceptionHandler - Handle exceptions in CLI mode with formatted output
*
* PRIORITY: 10 (high priority - runs first)
*
* This handler processes exceptions when running in CLI mode (artisan commands).
* It provides formatted, colored output optimized for terminal display with:
* - Exception type and location
* - Wrapped error message
* - Stack trace (last 10 calls)
* - Console debug messages (if any)
*
* When this handler handles an exception, it outputs to STDERR and exits with code 1.
*/
class Cli_Exception_Handler extends Rsx_Exception_Handler_Abstract
{
/**
* Get priority - CLI handlers run first
*
* @return int
*/
public static function get_priority(): int
{
return 10;
}
/**
* Handle exception if in CLI mode
*
* @param Throwable $e
* @param Request $request
* @return mixed Response if handled (exits), null if not CLI mode
*/
public function handle(Throwable $e, Request $request)
{
// Only handle in CLI mode
if (!app()->runningInConsole()) {
return null;
}
// ANSI color codes
$reset = "\033[0m";
$bold = "\033[1m";
$bold_orange = "\033[1;38;5;208m";
$amber = "\033[33m";
$white = "\033[37m";
$bold_white = "\033[1;37m";
// Get exception class name (without namespace)
$exception_type = (new \ReflectionClass($e))->getShortName();
// Format file path (remove base path for readability)
$file = str_replace(base_path() . '/', '', $e->getFile());
// Build formatted error output
$error_output = "\n";
$error_output .= "Fatal {$bold_orange}{$exception_type}{$reset} on {$amber}{$file}{$white}:{$amber}{$e->getLine()}{$reset}\n";
$error_output .= "\n";
// Format error message with word wrapping if terminal width detected
$error_message = $e->getMessage();
$terminal_width = 0;
try {
// Try to get terminal width
$terminal = new \Symfony\Component\Console\Terminal();
$terminal_width = $terminal->getWidth();
} catch (\Exception $ex) {
// Ignore, use default formatting
}
if ($terminal_width > 50) { // Valid width detected
// Word wrap with 2-space indent
$max_width = $terminal_width - 2; // Account for indent
$wrapped_lines = [];
$words = explode(' ', $error_message);
$current_line = '';
foreach ($words as $word) {
$test_line = $current_line ? $current_line . ' ' . $word : $word;
if (strlen($test_line) > $max_width) {
if ($current_line) {
$wrapped_lines[] = $current_line;
$current_line = $word;
} else {
// Single word longer than max width, force break
$wrapped_lines[] = $word;
$current_line = '';
}
} else {
$current_line = $test_line;
}
}
if ($current_line) {
$wrapped_lines[] = $current_line;
}
// Add each line with indent and color
foreach ($wrapped_lines as $line) {
$error_output .= " {$bold_white}{$line}{$reset}\n";
}
} else {
// Default: no wrapping, no indent
$error_output .= "{$bold_white}{$error_message}{$reset}\n";
}
$error_output .= "\n";
$error_output .= "Stack Trace (last 10 calls):\n";
// Get stack trace
$trace = $e->getTrace();
$count = 0;
foreach ($trace as $frame) {
if ($count >= 10) {
break;
}
$file = $frame['file'] ?? 'unknown';
$line = $frame['line'] ?? 0;
$function = $frame['function'] ?? 'unknown';
$class = $frame['class'] ?? '';
$type = $frame['type'] ?? '';
if ($class) {
$function = $class . $type . $function;
}
$error_output .= sprintf(
" #%d %s:%d %s()\n",
$count,
$file,
$line,
$function
);
$count++;
}
// Output console debug messages if enabled and any exist
if (!app()->environment('production')) {
$console_messages = \App\RSpade\Core\Debug\Debugger::_get_console_messages();
if (!empty($console_messages)) {
$error_output .= "\nConsole Debug Messages:\n";
foreach ($console_messages as $message) {
// Messages are structured arrays with channel and arguments
if (is_array($message) && count($message) >= 2) {
$channel_line = $message[0];
$arguments = $message[1];
$error_output .= ' ' . $channel_line;
foreach ($arguments as $arg) {
if (is_scalar($arg) || is_null($arg)) {
$output = is_bool($arg) ? ($arg ? 'true' : 'false') :
(is_null($arg) ? 'null' : (string)$arg);
$error_output .= ' ' . $output;
}
}
$error_output .= "\n";
}
}
}
}
// Output to STDERR and exit with error code
fwrite(STDERR, $error_output);
exit(1);
}
}

View File

@@ -0,0 +1,146 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*
*/
namespace App\RSpade\Core\Exceptions;
use Illuminate\Foundation\Exceptions\Handler;
use Illuminate\Http\Request;
use RuntimeException;
use Throwable;
use App\RSpade\Core\Exceptions\Rsx_Exception_Handler_Abstract;
/**
* Rsx_ExceptionHandler - Main exception handler for RSX framework
*
* FILE-SUBCLASS-01*: Extends Laravel's Handler but uses compound suffix ExceptionHandler
* This is intentional for consistency with other *_ExceptionHandler classes
*
* HOW THIS IS CALLED:
* File: bootstrap/app.php
* Mechanism: Laravel's exception handler registration
* Code: $app->singleton(ExceptionHandler::class, Rsx_ExceptionHandler::class)
*
* WHAT IT DOES:
* Implements a chain-of-responsibility pattern for exception handling:
* 1. Loads exception handler classes from config/rsx.php
* 2. Sorts handlers by priority (lower number = higher priority)
* 3. Calls each handler's handle() method in order
* 4. Returns first non-null response
* 5. Falls back to Laravel's default handling if no handler responds
*
* HANDLER EXECUTION ORDER:
* - Priority 10: CLI exceptions (formatted output for terminal)
* - Priority 20: AJAX exceptions (JSON error responses)
* - Priority 30: Playwright test exceptions (plain text output)
* - Priority 1000: RSX dispatch bootstrapper (404 RSX routing)
*
* EXTENSIBILITY:
* Users can add/remove/reorder handlers by modifying config/rsx.php:
* 'exception_handlers' => [
* \App\MyApp\My_Custom_ExceptionHandler::class,
* \App\RSpade\Core\Exceptions\Cli_ExceptionHandler::class,
* // ... etc
* ]
*/
#[Instantiatable]
class Rsx_Exception_Handler extends Handler
{
/**
* A list of exception types with their corresponding custom log levels.
*
* @var array<class-string<Throwable>, \Psr\Log\LogLevel::*>
*/
protected $levels = [];
/**
* A list of the exception types that are not reported.
*
* @var array<int, class-string<Throwable>>
*/
protected $dontReport = [];
/**
* A list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*
* @return void
*/
public function register()
{
$this->reportable(function (Throwable $e) {
// Default reportable - can be extended
});
// Main renderable - executes the handler chain
$this->renderable(function (Throwable $e, Request $request) {
// Get handler classes from config
$handler_classes = config('rsx.exception_handlers', []);
if (empty($handler_classes)) {
// No handlers configured, fall back to default Laravel handling
return null;
}
// Validate and collect handlers with their priorities
$handlers_with_priority = [];
foreach ($handler_classes as $handler_class) {
// Validate handler class exists
if (!class_exists($handler_class)) {
throw new RuntimeException(
"Exception handler class not found: {$handler_class}. " .
"Check your config/rsx.php 'exception_handlers' array."
);
}
// Validate handler extends abstract base
if (!is_subclass_of($handler_class, Rsx_Exception_Handler_Abstract::class)) {
throw new RuntimeException(
"Exception handler {$handler_class} must extend Rsx_Exception_Handler_Abstract. " .
"Check your config/rsx.php 'exception_handlers' array."
);
}
$handlers_with_priority[] = [
'class' => $handler_class,
'priority' => $handler_class::get_priority(),
];
}
// Sort by priority (lower number = higher priority)
usort($handlers_with_priority, function ($a, $b) {
return $a['priority'] <=> $b['priority'];
});
// Execute handlers in priority order
foreach ($handlers_with_priority as $handler_info) {
$handler_class = $handler_info['class'];
$handler = new $handler_class();
$response = $handler->handle($e, $request);
// If handler returned a response, use it
if ($response !== null) {
return $response;
}
}
// No handler handled the exception, continue to Laravel's default handling
return null;
});
}
}

View File

@@ -0,0 +1,79 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Exceptions;
use Illuminate\Http\Request;
use Throwable;
/**
* Rsx_Exception_Handler_Abstract - Base class for RSX exception handlers
*
* This abstract class defines the contract for exception handlers in the RSX framework.
* Handlers are executed in priority order (lower number = higher priority) and can
* choose to handle an exception by returning a response, or pass it to the next handler
* by returning null.
*
* HANDLER CHAIN:
* - Handlers are registered in config/rsx.php under 'exception_handlers'
* - Main Rsx_ExceptionHandler loads and sorts handlers by priority
* - Each handler's handle() method is called in order
* - First non-null response is returned
* - If all handlers return null, Laravel's default handling is used
*
* CREATING A HANDLER:
* 1. Extend this abstract class
* 2. Implement handle() method
* 3. Optionally override get_priority() (default is 100)
* 4. Add to config/rsx.php 'exception_handlers' array
*
* EXAMPLE:
* class My_Custom_ExceptionHandler extends Rsx_Exception_Handler_Abstract
* {
* public static function get_priority(): int { return 50; }
*
* public function handle(Throwable $e, Request $request)
* {
* if (!($e instanceof MyException)) {
* return null; // Not my concern
* }
*
* return response('Custom error', 500);
* }
* }
*/
#[Instantiatable]
abstract class Rsx_Exception_Handler_Abstract
{
/**
* Try to handle the exception
*
* @param Throwable $e The exception to handle
* @param Request $request The current request
* @return mixed Response if this handler handles the exception, null to pass to next handler
*/
abstract public function handle(Throwable $e, Request $request);
/**
* Get priority for this handler
*
* Lower numbers have higher priority and run first.
* Default is 100 for normal handlers.
*
* Suggested priority ranges:
* - 1-50: Critical/environment-specific handlers (CLI, AJAX)
* - 51-100: Standard handlers
* - 101-500: Low priority handlers
* - 501+: Fallback* or catch-all handlers
*
* @return int Priority value (lower = higher priority)
*/
public static function get_priority(): int
{
return 100;
}
}

View File

@@ -0,0 +1,205 @@
<?php
namespace App\RSpade\Core;
/**
* ExtensionRegistry - Central registry for file extensions and their handlers
*
* This class provides a plugin point for integrations to register new file types
* that should be processed by the Manifest and Bundle systems. It follows the
* static design pattern as per framework philosophy.
*
* USAGE:
* ExtensionRegistry::register_extension('jqhtml', 'JqhtmlHandler', 100);
* ExtensionRegistry::register_handler('jqhtml', function($path, &$data) { ... });
*
* The registry maintains:
* - File extensions that should be discovered
* - Handler callbacks for processing each extension
* - Priority ordering for handler execution
*/
class ExtensionRegistry
{
/**
* Registered extensions and their metadata
* Format: ['extension' => ['handler_class' => string, 'priority' => int]]
*/
protected static array $extensions = [];
/**
* Processing callbacks for each extension
* Format: ['extension' => callable]
*/
protected static array $handlers = [];
/**
* Core extensions that are always processed
* These are built into the framework and cannot be unregistered
*/
protected const CORE_EXTENSIONS = [
'php' => ['priority' => 100],
'js' => ['priority' => 200],
'jsx' => ['priority' => 200],
'ts' => ['priority' => 200],
'tsx' => ['priority' => 200],
'blade.php' => ['priority' => 300],
'phtml' => ['priority' => 300],
'scss' => ['priority' => 400],
'less' => ['priority' => 400],
'css' => ['priority' => 500],
];
/**
* Register a file extension for processing
*
* @param string $extension File extension without dot (e.g. 'jqhtml')
* @param string|null $handler_class Optional handler class name
* @param int $priority Processing priority (lower = earlier)
*/
public static function register_extension(string $extension, ?string $handler_class = null, int $priority = 1000): void
{
// Don't allow overriding core extensions
if (isset(static::CORE_EXTENSIONS[$extension])) {
throw new \RuntimeException("Cannot override core extension: {$extension}");
}
static::$extensions[$extension] = [
'handler_class' => $handler_class,
'priority' => $priority
];
}
/**
* Register a processing handler for an extension
*
* The handler receives the file path and metadata array by reference
* and should modify the metadata array with extracted information.
*
* @param string $extension File extension
* @param callable $handler Processing callback
*/
public static function register_handler(string $extension, callable $handler): void
{
static::$handlers[$extension] = $handler;
}
/**
* Get all registered extensions (core + custom)
*
* @return array List of extensions
*/
public static function get_all_extensions(): array
{
$all = array_keys(static::CORE_EXTENSIONS);
$custom = array_keys(static::$extensions);
return array_unique(array_merge($all, $custom));
}
/**
* Check if an extension is registered
*
* @param string $extension Extension to check
* @return bool
*/
public static function is_registered(string $extension): bool
{
return isset(static::CORE_EXTENSIONS[$extension]) ||
isset(static::$extensions[$extension]);
}
/**
* Get handler for an extension
*
* @param string $extension Extension
* @return callable|null Handler callback or null
*/
public static function get_handler(string $extension): ?callable
{
return static::$handlers[$extension] ?? null;
}
/**
* Process a file through its registered handler
*
* @param string $extension File extension
* @param string $file_path Path to file
* @param array $metadata Metadata array (modified by reference)
* @return bool True if processed, false if no handler
*/
public static function process_file(string $extension, string $file_path, array &$metadata): bool
{
$handler = static::get_handler($extension);
if ($handler === null) {
return false;
}
$handler($file_path, $metadata);
return true;
}
/**
* Get extensions sorted by priority
*
* @return array Extensions in priority order
*/
public static function get_sorted_extensions(): array
{
// Combine core and custom extensions
$all = static::CORE_EXTENSIONS;
foreach (static::$extensions as $ext => $data) {
$all[$ext] = ['priority' => $data['priority']];
}
// Sort by priority
uasort($all, fn($a, $b) => $a['priority'] <=> $b['priority']);
return array_keys($all);
}
/**
* Clear all custom registrations (for testing)
*/
public static function clear_custom(): void
{
static::$extensions = [];
static::$handlers = [];
}
/**
* Unregister a custom extension
*
* @param string $extension Extension to remove
* @return bool True if removed, false if not found or core
*/
public static function unregister(string $extension): bool
{
if (isset(static::CORE_EXTENSIONS[$extension])) {
return false; // Cannot unregister core
}
if (isset(static::$extensions[$extension])) {
unset(static::$extensions[$extension]);
unset(static::$handlers[$extension]);
return true;
}
return false;
}
/**
* Get metadata for an extension
*
* @param string $extension Extension
* @return array|null Metadata or null if not found
*/
public static function get_extension_metadata(string $extension): ?array
{
if (isset(static::CORE_EXTENSIONS[$extension])) {
return static::CORE_EXTENSIONS[$extension];
}
return static::$extensions[$extension] ?? null;
}
}

View File

@@ -0,0 +1,35 @@
<?php
/**
* Override Laravel's redirect helper to use relative URLs
* This ensures redirects are protocol and domain agnostic
*/
namespace App\RSpade\Core\Helpers;
/**
* Create a redirect response to the given path
* Forces relative URLs regardless of APP_URL or request protocol
*
* @param string $path
* @param int $status
* @param array $headers
* @param bool|null $secure
* @return \Illuminate\Http\RedirectResponse
*/
function rsx_redirect($path, $status = 302, $headers = [], $secure = null)
{
// Ensure path starts with /
if (!str_starts_with($path, '/')) {
$path = '/' . $path;
}
// Create a redirect response with just the path (no domain/protocol)
$response = new \Illuminate\Http\RedirectResponse($path, $status, $headers);
// Set the session on the response
if (app()->bound('session')) {
$response->setSession(app('session'));
}
return $response;
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\RSpade\Core;
use RuntimeException;
use App\RSpade\Core\Bundle\BundleIntegration_Abstract;
/**
* IntegrationRegistry - Central registry for framework integrations
*
* This class tracks all registered integrations and provides
* a central access point for the framework to interact with them.
* Integrations are registered via service providers.
*/
class IntegrationRegistry
{
/**
* Registered integrations
* Format: ['name' => integration_class]
*/
protected static array $integrations = [];
/**
* Register an integration
*
* @param string $integration_class The fully qualified class name of the integration
* @return void
*/
public static function register(string $integration_class): void
{
// Validate the class extends BundleIntegration_Abstract
if (!is_subclass_of($integration_class, BundleIntegration_Abstract::class)) {
throw new RuntimeException("Integration class {$integration_class} must extend BundleIntegration_Abstract");
}
$name = $integration_class::get_name();
static::$integrations[$name] = $integration_class;
}
/**
* Get all registered integrations
*
* @return array Array of integration class names
*/
public static function get_all(): array
{
return array_values(static::$integrations);
}
/**
* Get an integration by name
*
* @param string $name Integration name
* @return string|null Integration class name or null if not found
*/
public static function get(string $name): ?string
{
return static::$integrations[$name] ?? null;
}
/**
* Check if an integration is registered
*
* @param string $name Integration name
* @return bool
*/
public static function has(string $name): bool
{
return isset(static::$integrations[$name]);
}
/**
* Clear all registered integrations
* (Mainly for testing purposes)
*
* @return void
*/
public static function clear(): void
{
static::$integrations = [];
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\RSpade\Core;
use Illuminate\Support\ServiceProvider;
use App\RSpade\Core\ExtensionRegistry;
use App\RSpade\Core\IntegrationRegistry;
use App\RSpade\Core\Kernels\ManifestKernel;
/**
* Integration_Service_Provider_Abstract - Base class for integration service providers
*
* This abstract class provides the foundation for registering external integrations
* with the RSX framework. It handles the common registration tasks:
* - Registering file extensions with ExtensionRegistry
* - Registering processors with BundleCompiler
* - Registering manifest modules with ManifestKernel
* - Bootstrapping the integration
*
* USAGE:
* class Jqhtml_Service_Provider extends Integration_Service_Provider_Abstract {
* protected function get_integration_class(): string {
* return Jqhtml_BundleIntegration::class;
* }
* }
*
* Then register the provider in config/app.php or a package service provider.
*/
#[Instantiatable]
abstract class Integration_Service_Provider_Abstract extends ServiceProvider
{
/**
* The integration instance
*/
protected ?BundleIntegration_Abstract $integration = null;
/**
* Get the integration class for this provider
*
* Concrete classes must implement this to return their integration class name.
*
* @return string
*/
abstract protected function get_integration_class(): string;
/**
* Register services
*/
public function register(): void
{
$integration_class = $this->get_integration_class();
// Check if integration is enabled
if (!$integration_class::is_enabled()) {
return;
}
// Register integration with the IntegrationRegistry
IntegrationRegistry::register($integration_class);
// Register file extensions
foreach ($integration_class::get_file_extensions() as $extension) {
ExtensionRegistry::register_extension(
$extension,
$integration_class::get_processor(),
$integration_class::get_priority()
);
// Note: Processors are now configured globally in config/rsx.php
// No need to register them here
}
// Register manifest module
$manifest_module = $integration_class::get_manifest_module();
if ($manifest_module && $this->app->has(ManifestKernel::class)) {
$this->app->make(ManifestKernel::class)->register($manifest_module);
}
}
/**
* Bootstrap services
*/
public function boot(): void
{
$integration_class = $this->get_integration_class();
// Check if integration is enabled
if (!$integration_class::is_enabled()) {
return;
}
// Register extension handlers with ExtensionRegistry
$manifest_module = $integration_class::get_manifest_module();
if ($manifest_module) {
// Fail loud - let PHP throw error if class doesn't exist
$module = new $manifest_module();
// Register handler for each extension
foreach ($integration_class::get_file_extensions() as $extension) {
ExtensionRegistry::register_handler($extension, function($path, &$metadata) use ($module) {
$metadata = $module->process($path, $metadata);
});
}
}
// Bootstrap the integration
$integration_class::bootstrap();
}
/**
* Check dependencies and ensure they're loaded
*
* @throws \RuntimeException if dependencies are not met
*/
protected function check_dependencies(): void
{
$integration_class = $this->get_integration_class();
$dependencies = $integration_class::get_dependencies();
foreach ($dependencies as $dependency) {
// This would check if the dependency integration is registered
// Implementation depends on how we track registered integrations
}
}
/**
* Merge integration config with application config
*/
protected function merge_config(): void
{
$integration_class = $this->get_integration_class();
$name = $integration_class::get_name();
$config = $integration_class::get_config();
if (!empty($config)) {
$this->mergeConfigFrom($config, "rsx.integrations.{$name}");
}
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace App\RSpade\Core\JavaScript;
use RuntimeException;
/**
* Exception for JavaScript parsing and transformation errors
*/
#[Instantiatable]
class Js_Exception extends RuntimeException
{
protected string $filePath;
protected int $lineNumber = 0;
protected ?int $column = null;
protected ?string $context = null;
protected ?string $suggestion = null;
protected string $rawMessage = '';
/**
* Create a new JavaScript exception
*/
public function __construct(string $message, string $file, int $line = 0, int $column = 0, ?\Throwable $previous = null)
{
// Store the raw message and properties
$this->rawMessage = $message;
$this->filePath = $file;
$this->lineNumber = $line;
$this->column = $column;
// Clean up the file path for display
$displayFile = str_replace(base_path() . '/', '', $file);
// Build the display message
$displayMessage = "JavaScript error in {$displayFile}";
if ($line > 0) {
$displayMessage .= " (line {$line}";
if ($column > 0) {
$displayMessage .= ":{$column}";
}
$displayMessage .= ")";
}
$displayMessage .= "\n{$message}";
// Call parent constructor
parent::__construct($displayMessage, 0, $previous);
// Set file and line for PHP's getFile() and getLine()
$this->file = $file;
$this->line = $line;
}
/**
* Get the column number
*/
public function getColumn(): ?int
{
return $this->column;
}
/**
* Set the column number
*/
public function setColumn(int $column): void
{
$this->column = $column;
}
/**
* Get the code context
*/
public function getContext(): ?string
{
return $this->context;
}
/**
* Set the code context
*/
public function setContext(string $context): void
{
$this->context = $context;
}
/**
* Get the suggestion
*/
public function getSuggestion(): ?string
{
return $this->suggestion;
}
/**
* Set the suggestion
*/
public function setSuggestion(string $suggestion): void
{
$this->suggestion = $suggestion;
}
/**
* Get the raw unformatted message
*/
public function getRawMessage(): string
{
return $this->rawMessage;
}
/**
* Get the file path
*/
public function getFilePath(): string
{
return $this->filePath;
}
/**
* Get the line number
*/
public function getLineNumber(): int
{
return $this->lineNumber;
}
}

View File

@@ -0,0 +1,502 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\JavaScript;
use Illuminate\Support\Facades\File;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use App\RSpade\Core\JavaScript\Js_Exception;
class Js_Parser
{
/**
* Node.js parser script path
*/
protected const PARSER_SCRIPT = 'app/RSpade/Core/JavaScript/resource/js-parser.js';
/**
* Cache directory for parsed JavaScript files
*/
protected const CACHE_DIR = 'storage/rsx-tmp/persistent/js_parser';
/**
* Parse a JavaScript file using Node.js AST parser with caching
*/
public static function parse($file_path)
{
// Generate cache key using the file hash
$cache_key = _rsx_file_hash_for_build($file_path);
$cache_file = base_path(self::CACHE_DIR . '/' . $cache_key . '.json');
// Check if cached result exists
if (file_exists($cache_file)) {
$cached_data = file_get_contents($cache_file);
$parsed_data = json_decode($cached_data, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $parsed_data;
}
// Cache is corrupt, delete it and continue to parse
@unlink($cache_file);
}
// Parse the file (original logic continues below)
$result = static::_parse_without_cache($file_path);
// Cache the result
static::_cache_result($cache_key, $result);
return $result;
}
/**
* Parse without using cache
*/
protected static function _parse_without_cache($file_path)
{
// Always use advanced parser for decorator support
// The simple parser is deprecated and doesn't support modern ES features
$parser_path = base_path(self::PARSER_SCRIPT);
if (!File::exists($parser_path)) {
throw new \RuntimeException("No JavaScript parser available. Please ensure Node.js and babel parser are installed.");
}
$process = new Process([
'node',
$parser_path,
'--json', // Use JSON output for structured error reporting
$file_path
]);
// Set working directory to base path to find node_modules
$process->setWorkingDirectory(base_path());
$process->setTimeout(10); // 10 second timeout
try {
$process->run();
$output = $process->getOutput();
if (empty($output)) {
throw new \RuntimeException("JavaScript parser returned empty output for {$file_path}");
}
// Parse JSON output
$result = @json_decode($output, true);
if (!$result || !is_array($result)) {
// Handle non-JSON output (shouldn't happen with --json flag)
throw new \RuntimeException(
"JavaScript parser returned invalid JSON for {$file_path}:\n" . $output
);
}
if ($result['status'] === 'success') {
return $result['result'];
}
// Handle error response
if ($result['status'] === 'error' && isset($result['error'])) {
$error = $result['error'];
$error_type = $error['type'] ?? 'Unknown';
$message = $error['message'] ?? 'Unknown error';
$line = $error['line'] ?? 0;
$column = $error['column'] ?? 0;
$code = $error['code'] ?? null;
$suggestion = $error['suggestion'] ?? null;
// Handle specific error types from structure validation
switch ($error_type) {
case 'ModuleExportsFound':
throw new Js_Exception(
"Module exports detected. JavaScript files are concatenated, use direct class references.",
$file_path,
$line
);
case 'CodeOutsideAllowed':
$error_msg = "JavaScript files without classes may only contain function declarations and comments.";
if ($code) {
$error_msg .= "\nFound: {$code}";
}
throw new Js_Exception(
$error_msg,
$file_path,
$line
);
case 'CodeOutsideClass':
$error_msg = "JavaScript files with classes may only contain one class declaration and comments.";
if ($code) {
$error_msg .= "\nFound: {$code}";
}
throw new Js_Exception(
$error_msg,
$file_path,
$line
);
case 'InstanceMethodDecorator':
throw new Js_Exception(
"Decorators only allowed on static methods. Instance methods cannot have decorators.",
$file_path,
$line
);
default:
// Clean up the message - remove redundant file path info
$message = preg_replace('/^Parse error:\s*/', '', $message);
// Create Js_Exception with line/column info
$exception = new Js_Exception(
$message,
$file_path,
$line,
$column
);
if ($suggestion) {
$exception->setSuggestion($suggestion);
}
throw $exception;
}
}
// Unknown response format
throw new \RuntimeException(
"JavaScript parser returned unexpected response for {$file_path}:\n" .
json_encode($result, JSON_PRETTY_PRINT)
);
} catch (ProcessFailedException $e) {
// Process failed to run at all
$error_output = $process->getErrorOutput();
// Check for missing Node.js
if (str_contains($e->getMessage(), 'No such file or directory') &&
str_contains($e->getMessage(), 'node')) {
throw new \RuntimeException(
"Node.js is not installed or not in PATH.\n" .
"Install Node.js to parse JavaScript files."
);
}
// Generic process failure
throw new \RuntimeException(
"Failed to run JavaScript parser for {$file_path}:\n" .
$e->getMessage() . "\n" .
"Error output: " . $error_output
);
} catch (Js_Exception $e) {
// Re-throw JavaScript exceptions
throw $e;
} catch (\Exception $e) {
// Wrap other exceptions
throw new \RuntimeException(
"JavaScript parser error for {$file_path}: " . $e->getMessage()
);
}
}
/**
* Cache the parser result
*/
protected static function _cache_result($cache_key, $result)
{
$cache_dir = base_path(self::CACHE_DIR);
// Ensure cache directory exists
if (!is_dir($cache_dir)) {
mkdir($cache_dir, 0755, true);
}
$cache_file = $cache_dir . '/' . $cache_key . '.json';
$json_data = json_encode($result);
if (json_last_error() !== JSON_ERROR_NONE) {
// Don't cache if JSON encoding failed
return;
}
file_put_contents($cache_file, $json_data);
}
/**
* Create the Node.js parser script
*/
protected static function __create_parser_script($parser_path)
{
$script = <<<'JS'
const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
// Get file path from command line
const filePath = process.argv[2];
if (!filePath) {
console.error('Usage: node js-parser.js <file-path>');
process.exit(1);
}
// Read file
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (error) {
console.error(`Error reading file: ${error.message}`);
process.exit(1);
}
// Parse result object
const result = {
classes: {},
functions: {},
exports: {},
imports: []
};
try {
// Parse with Babel
const ast = parser.parse(content, {
sourceType: 'module',
plugins: [
'jsx',
'typescript',
'classProperties',
'classPrivateProperties',
'classPrivateMethods',
'decorators-legacy',
'dynamicImport',
'exportDefaultFrom',
'exportNamespaceFrom',
'asyncGenerators',
'objectRestSpread',
'optionalCatchBinding',
'optionalChaining',
'nullishCoalescingOperator'
]
});
// Traverse AST
traverse(ast, {
// Class declarations
ClassDeclaration(path) {
const className = path.node.id.name;
const classInfo = {
name: className,
extends: path.node.superClass ? path.node.superClass.name : null,
methods: {},
staticMethods: {},
properties: {},
staticProperties: {}
};
// Extract methods and properties
path.node.body.body.forEach(member => {
if (member.type === 'ClassMethod') {
const methodInfo = {
name: member.key.name,
params: member.params.map(p => p.name || p.type),
async: member.async,
generator: member.generator
};
if (member.static) {
classInfo.staticMethods[member.key.name] = methodInfo;
} else {
classInfo.methods[member.key.name] = methodInfo;
}
} else if (member.type === 'ClassProperty') {
const propInfo = {
name: member.key.name,
static: member.static,
value: member.value ? getValueType(member.value) : null
};
if (member.static) {
classInfo.staticProperties[member.key.name] = propInfo;
} else {
classInfo.properties[member.key.name] = propInfo;
}
}
});
result.classes[className] = classInfo;
},
// Function declarations
FunctionDeclaration(path) {
const funcName = path.node.id.name;
result.functions[funcName] = {
name: funcName,
params: path.node.params.map(p => p.name || p.type),
async: path.node.async,
generator: path.node.generator
};
},
// Imports
ImportDeclaration(path) {
result.imports.push({
source: path.node.source.value,
specifiers: path.node.specifiers.map(spec => ({
type: spec.type,
local: spec.local.name,
imported: spec.imported ? spec.imported.name : null
}))
});
},
// Exports
ExportNamedDeclaration(path) {
if (path.node.declaration) {
if (path.node.declaration.type === 'ClassDeclaration') {
result.exports[path.node.declaration.id.name] = 'class';
} else if (path.node.declaration.type === 'FunctionDeclaration') {
result.exports[path.node.declaration.id.name] = 'function';
}
}
},
ExportDefaultDeclaration(path) {
result.exports.default = getExportType(path.node.declaration);
}
});
} catch (error) {
console.error(`Parse error: ${error.message}`);
process.exit(1);
}
// Helper functions
function getValueType(node) {
if (node.type === 'StringLiteral') return node.value;
if (node.type === 'NumericLiteral') return node.value;
if (node.type === 'BooleanLiteral') return node.value;
if (node.type === 'NullLiteral') return null;
if (node.type === 'Identifier') return node.name;
return node.type;
}
function getExportType(node) {
if (node.type === 'ClassDeclaration') return 'class';
if (node.type === 'FunctionDeclaration') return 'function';
if (node.type === 'Identifier') return 'identifier';
return node.type;
}
// Output result as JSON
console.log(JSON.stringify(result, null, 2));
JS;
File::ensureDirectoryExists(dirname($parser_path));
File::put($parser_path, $script);
// Also create package.json for dependencies
$package_json = [
'name' => 'rsx-js-parser',
'version' => '1.0.0',
'description' => 'JavaScript parser for RSX manifest system',
'dependencies' => [
'@babel/parser' => '^7.22.0',
'@babel/traverse' => '^7.22.0'
]
];
File::put(dirname($parser_path) . '/package.json', json_encode($package_json, JSON_PRETTY_PRINT));
}
/**
* Extract JavaScript metadata in manifest-ready format
* This is the high-level method that Manifest should call
*
* @param string $file_path Path to JavaScript file
* @return array Manifest-ready metadata
*/
public static function extract_metadata(string $file_path): array
{
$data = [];
// Use static parser to get raw parsed data
$parsed = static::parse($file_path);
if (!empty($parsed['classes'])) {
$first_class = reset($parsed['classes']);
$data['class'] = $first_class['name'];
if ($first_class['extends']) {
// Check for period in extends clause
if (str_contains($first_class['extends'], '.')) {
\App\RSpade\Core\Manifest\ManifestErrors::js_extends_with_period($file_path, $first_class['name'], $first_class['extends']);
}
$data['extends'] = $first_class['extends'];
}
// For JS files, we use consistent naming with PHP
$data['public_instance_methods'] = $first_class['public_instance_methods'] ?? [];
$data['public_static_methods'] = $first_class['public_static_methods'] ?? [];
$data['static_properties'] = $first_class['staticProperties'] ?? [];
// Store decorators if present
// JS decorators now use same compact format as PHP: [[name, [args]], ...]
if (!empty($first_class['decorators'])) {
$data['decorators'] = $first_class['decorators'];
}
// Extract method decorators in compact format
// Note: js-parser.js already returns decorators in compact format [[name, [args]], ...]
// so we don't need to call compact_decorators() here
$method_decorators = [];
// Process regular methods
if (!empty($first_class['public_instance_methods'])) {
foreach ($first_class['public_instance_methods'] as $method_name => $method_info) {
if (!empty($method_info['decorators'])) {
$method_decorators[$method_name] = $method_info['decorators'];
}
}
}
// Process static methods
if (!empty($first_class['public_static_methods'])) {
foreach ($first_class['public_static_methods'] as $method_name => $method_info) {
if (!empty($method_info['decorators'])) {
$method_decorators[$method_name] = $method_info['decorators'];
}
}
}
// Store method decorators if any found
if (!empty($method_decorators)) {
$data['method_decorators'] = $method_decorators;
}
}
if (!empty($parsed['imports'])) {
$data['imports'] = $parsed['imports'];
}
if (!empty($parsed['exports'])) {
$data['exports'] = $parsed['exports'];
}
// Store global function names for uniqueness checking
if (!empty($parsed['globalFunctions'])) {
$data['global_function_names'] = $parsed['globalFunctions'];
}
// Store global const names for uniqueness checking
if (!empty($parsed['globalConstants'])) {
$data['global_const_names'] = $parsed['globalConstants'];
}
// Store global functions that have decorators
if (!empty($parsed['functionsWithDecorators'])) {
$data['global_functions_with_decorators'] = $parsed['functionsWithDecorators'];
}
return $data;
}
}

View File

@@ -0,0 +1,290 @@
<?php
/**
* JavaScript Transformer using Babel
*
* Transpiles modern JavaScript features (decorators) to compatible code.
* Private fields (#private) are NOT transpiled - native browser support is used.
*
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\JavaScript;
use Illuminate\Support\Facades\File;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;
use RuntimeException;
class Js_Transformer
{
/**
* Node.js transformer script path
*/
protected const TRANSFORMER_SCRIPT = 'app/RSpade/Core/JavaScript/resource/js-transformer.js';
/**
* Cache directory for transformed JavaScript files
*/
protected const CACHE_DIR = 'storage/rsx-tmp/babel_cache';
/**
* Transform a JavaScript file using Babel
*
* @param string $file_path Path to JavaScript file
* @param string $target Target environment (modern, es6, es5)
* @return string Transformed JavaScript code
*/
public static function transform(string $file_path, string $target = 'modern'): string
{
// Generate cache key using file hash and target
$cache_key = _rsx_file_hash_for_build($file_path) . '_' . $target;
$cache_file = base_path(self::CACHE_DIR . '/' . $cache_key . '.js');
// Check if cached result exists
if (file_exists($cache_file)) {
$mtime_cache = filemtime($cache_file);
$mtime_source = filemtime($file_path);
if ($mtime_cache >= $mtime_source) {
return file_get_contents($cache_file);
}
}
// Transform the file
$result = static::_transform_without_cache($file_path, $target);
// Cache the result
static::_cache_result($cache_key, $result);
return $result;
}
/**
* Transform a JavaScript string using Babel
*
* @param string $js_code JavaScript code to transform
* @param string $file_path Original file path (for hash generation)
* @param string $target Target environment (modern, es6, es5)
* @return string Transformed JavaScript code
*/
public static function transform_string(string $js_code, string $file_path, string $target = 'modern'): string
{
// Create temporary file
$temp_file = tempnam(sys_get_temp_dir(), 'babel_');
file_put_contents($temp_file, $js_code);
try {
// Transform using temporary file
$result = static::_transform_without_cache($temp_file, $target, $file_path);
return $result;
} finally {
// Clean up temporary file
@unlink($temp_file);
}
}
/**
* Transform without using cache
*
* @param string $file_path Path to file to transform
* @param string $target Target environment
* @param string|null $original_path Original file path for hash (if using temp file)
* @return string Transformed code
*/
protected static function _transform_without_cache(string $file_path, string $target, ?string $original_path = null): string
{
$transformer_path = base_path(self::TRANSFORMER_SCRIPT);
if (!File::exists($transformer_path)) {
throw new RuntimeException("Babel transformer script not found at {$transformer_path}");
}
// Use original path for hash generation if provided (for temp files)
$hash_path = $original_path ?: $file_path;
$process = new Process([
'node',
$transformer_path,
'--json', // Use JSON output for structured error reporting
$file_path,
$target,
$hash_path // Pass the path for hash generation
]);
// Set working directory to base path to find node_modules
$process->setWorkingDirectory(base_path());
$process->setTimeout(30); // 30 second timeout
try {
$process->run();
$output = $process->getOutput();
if (empty($output)) {
throw new RuntimeException("Babel transformer returned empty output for {$file_path}");
}
// Parse JSON output
$result = @json_decode($output, true);
if (!$result || !is_array($result)) {
throw new RuntimeException(
"Babel transformer returned invalid JSON for {$file_path}:\n" . $output
);
}
if ($result['status'] === 'success') {
return $result['result'];
}
// Handle error response
if ($result['status'] === 'error' && isset($result['error'])) {
$error = $result['error'];
$message = $error['message'] ?? 'Unknown error';
$line = $error['line'] ?? null;
$column = $error['column'] ?? null;
$suggestion = $error['suggestion'] ?? null;
// Build error message
$error_msg = "JavaScript transformation failed";
if ($line && $column) {
$error_msg .= " at line {$line}, column {$column}";
}
$error_msg .= " in {$file_path}:\n{$message}";
if ($suggestion) {
$error_msg .= "\n\n{$suggestion}";
}
// Check for specific error types
if (str_contains($message, 'Cannot find module')) {
throw new RuntimeException(
"Babel packages not installed.\n" .
"Run: npm install\n" .
"Error: {$message}"
);
}
if (str_contains($message, 'No such file or directory') &&
str_contains($message, 'node')) {
throw new RuntimeException(
"Node.js is not installed or not in PATH.\n" .
"Install Node.js to use JavaScript transformation."
);
}
throw new RuntimeException($error_msg);
}
// Unknown response format
throw new RuntimeException(
"Babel transformer returned unexpected response for {$file_path}:\n" .
json_encode($result, JSON_PRETTY_PRINT)
);
} catch (ProcessFailedException $e) {
// Process failed to run at all
$error_output = $process->getErrorOutput();
// Check for missing Node.js
if (str_contains($e->getMessage(), 'No such file or directory') &&
str_contains($e->getMessage(), 'node')) {
throw new RuntimeException(
"Node.js is not installed or not in PATH.\n" .
"Install Node.js to use JavaScript transformation."
);
}
// Generic process failure
throw new RuntimeException(
"Failed to run Babel transformer for {$file_path}:\n" .
$e->getMessage() . "\n" .
"Error output: " . $error_output
);
}
}
/**
* Cache the transformer result
*
* @param string $cache_key Cache key
* @param string $result Transformed code
*/
protected static function _cache_result(string $cache_key, string $result): void
{
$cache_dir = base_path(self::CACHE_DIR);
// Ensure cache directory exists
if (!is_dir($cache_dir)) {
mkdir($cache_dir, 0755, true);
}
$cache_file = $cache_dir . '/' . $cache_key . '.js';
file_put_contents($cache_file, $result);
}
/**
* Clear the transformation cache
*/
public static function clear_cache(): void
{
$cache_dir = base_path(self::CACHE_DIR);
if (is_dir($cache_dir)) {
$files = glob($cache_dir . '/*.js');
foreach ($files as $file) {
@unlink($file);
}
}
}
/**
* Check if Babel is properly installed
*
* @return bool
*/
public static function is_available(): bool
{
$transformer_path = base_path(self::TRANSFORMER_SCRIPT);
if (!File::exists($transformer_path)) {
return false;
}
// Check if node_modules exists
$node_modules = dirname($transformer_path) . '/node_modules';
if (!is_dir($node_modules)) {
return false;
}
// Check for required packages
$required_packages = [
'@babel/core',
'@babel/preset-env',
'@babel/plugin-proposal-decorators'
];
foreach ($required_packages as $package) {
if (!is_dir($node_modules . '/' . $package)) {
return false;
}
}
return true;
}
/**
* Get list of required npm packages
*
* @return array
*/
public static function get_required_packages(): array
{
return [
'@babel/core' => '^7.24.0',
'@babel/preset-env' => '^7.24.0',
'@babel/plugin-proposal-decorators' => '^7.24.0'
];
}
}

View File

@@ -0,0 +1,815 @@
const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const t = require('@babel/types');
const generate = require('@babel/generator').default;
// Parse command line arguments
let filePath = null;
let jsonOutput = false;
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i];
if (arg === '--json') {
jsonOutput = true;
} else if (!filePath) {
filePath = arg;
}
}
// Error helper for JSON output
function outputError(error) {
if (jsonOutput) {
const errorObj = {
status: 'error',
error: {
type: error.constructor.name,
message: error.message,
line: error.loc ? error.loc.line : null,
column: error.loc ? error.loc.column : null,
file: filePath,
context: null,
suggestion: null
}
};
// Add suggestions for common errors
if (error.message.includes('Unexpected token')) {
errorObj.error.suggestion = 'Check JavaScript syntax - may have invalid characters or missing punctuation';
} else if (error.message.includes('Unterminated')) {
errorObj.error.suggestion = 'Check for unclosed strings, comments, or brackets';
}
console.log(JSON.stringify(errorObj));
} else {
console.error(`Parse error: ${error.message}`);
}
}
// Custom error for structure violations
function structureError(type, message, line, code = null) {
if (jsonOutput) {
console.log(JSON.stringify({
status: 'error',
error: {
type: type,
message: message,
line: line,
code: code,
file: filePath
}
}));
} else {
console.error(`${type}: ${message} at line ${line}`);
if (code) {
console.error(` Code: ${code}`);
}
}
process.exit(1);
}
if (!filePath) {
if (jsonOutput) {
console.log(JSON.stringify({
status: 'error',
error: {
type: 'ArgumentError',
message: 'No input file specified',
suggestion: 'Usage: node js-parser.js [--json] <file-path>'
}
}));
} else {
console.error('Usage: node js-parser.js [--json] <file-path>');
}
process.exit(1);
}
// Read file
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (error) {
error.message = `Error reading file: ${error.message}`;
outputError(error);
process.exit(1);
}
// Parse result object
const result = {
classes: {},
functions: {},
exports: {},
imports: [],
globalFunctions: [], // All global function names
globalConstants: [], // All global const names
functionsWithDecorators: {}, // Global functions that have decorators
instanceMethodsWithDecorators: {} // Instance methods with decorators (for error detection)
};
// Tracking for validation
let hasES6Class = false;
let moduleExportsFound = null;
let codeOutsideAllowed = [];
// Helper function to extract decorator information in PHP-compatible compact format
function extractDecorators(decorators) {
if (!decorators || decorators.length === 0) {
return [];
}
return decorators.map(decorator => {
const expr = decorator.expression;
// Handle simple decorators like @readonly
if (t.isIdentifier(expr)) {
return [expr.name, []];
}
// Handle decorators with arguments like @Route('/api')
if (t.isCallExpression(expr)) {
const name = t.isIdentifier(expr.callee) ? expr.callee.name :
t.isMemberExpression(expr.callee) ? getMemberExpressionName(expr.callee) :
'unknown';
const args = expr.arguments.map(arg => extractArgumentValue(arg));
return [name, args];
}
// Handle member expression decorators like @Namespace.Decorator
if (t.isMemberExpression(expr)) {
return [getMemberExpressionName(expr), []];
}
return ['unknown', []];
});
}
// Helper to get full name from member expression
function getMemberExpressionName(node) {
if (t.isIdentifier(node.object) && t.isIdentifier(node.property)) {
return `${node.object.name}.${node.property.name}`;
}
if (t.isIdentifier(node.property)) {
return node.property.name;
}
return 'unknown';
}
// Helper to extract argument values
function extractArgumentValue(node) {
if (t.isStringLiteral(node)) {
return node.value;
}
if (t.isNumericLiteral(node)) {
return node.value;
}
if (t.isBooleanLiteral(node)) {
return node.value;
}
if (t.isNullLiteral(node)) {
return null;
}
if (t.isIdentifier(node)) {
return { identifier: node.name };
}
if (t.isObjectExpression(node)) {
const obj = {};
node.properties.forEach(prop => {
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
obj[prop.key.name] = extractArgumentValue(prop.value);
}
});
return obj;
}
if (t.isArrayExpression(node)) {
return node.elements.map(el => el ? extractArgumentValue(el) : null);
}
if (t.isTemplateLiteral(node) && node.expressions.length === 0) {
// Simple template literal without expressions
return node.quasis[0].value.raw;
}
// For complex expressions, store the type
return { type: node.type };
}
// Helper to get snippet of code for error messages
function getCodeSnippet(node) {
try {
const generated = generate(node, { compact: true });
let code = generated.code;
// Truncate long code snippets
if (code.length > 60) {
code = code.substring(0, 60) + '...';
}
return code;
} catch (e) {
return node.type;
}
}
// Helper to check if a value is static (no function calls)
function isStaticValue(node) {
if (!node) return false;
// Literals are always static
if (t.isStringLiteral(node) || t.isNumericLiteral(node) ||
t.isBooleanLiteral(node) || t.isNullLiteral(node)) {
return true;
}
// Template literals without expressions are static
if (t.isTemplateLiteral(node) && node.expressions.length === 0) {
return true;
}
// Binary expressions with static operands (e.g., 3 * 365)
if (t.isBinaryExpression(node)) {
return isStaticValue(node.left) && isStaticValue(node.right);
}
// Unary expressions with static operands (e.g., -5, !true)
if (t.isUnaryExpression(node)) {
return isStaticValue(node.argument);
}
// Arrays with all static elements
if (t.isArrayExpression(node)) {
return node.elements.every(el => el === null || isStaticValue(el));
}
// Objects with all static properties
if (t.isObjectExpression(node)) {
return node.properties.every(prop => {
if (t.isObjectProperty(prop)) {
return isStaticValue(prop.value);
}
return false; // Methods are not static
});
}
// Everything else (identifiers, function calls, etc.) is not static
return false;
}
/**
* Helper function to check if a JSDoc comment contains @decorator tag
*/
function hasDecoratorTag(commentValue) {
if (!commentValue) return false;
// Parse JSDoc-style comment
// Look for @decorator anywhere in the comment
const lines = commentValue.split('\n');
for (const line of lines) {
// Remove leading asterisk and whitespace
const cleanLine = line.replace(/^\s*\*\s*/, '').trim();
// Check if line contains @decorator tag
if (cleanLine === '@decorator' || cleanLine.startsWith('@decorator ')) {
return true;
}
}
return false;
}
/**
* Extract JSDoc decorators from comments (e.g., @Instantiatable, @Monoprogenic)
* Returns compact format matching PHP: [[name, [args]], ...]
*/
function extractJSDocDecorators(leadingComments) {
const decorators = [];
if (!leadingComments) return decorators;
for (const comment of leadingComments) {
if (comment.type !== 'CommentBlock') continue;
const lines = comment.value.split('\n');
for (const line of lines) {
// Remove leading asterisk and whitespace
const cleanLine = line.replace(/^\s*\*\s*/, '').trim();
// Match @DecoratorName pattern (capital letter start, no spaces)
const match = cleanLine.match(/^@([A-Z][A-Za-z0-9_]*)\s*$/);
if (match) {
const decoratorName = match[1];
// Store in compact format matching PHP: [name, [args]]
decorators.push([decoratorName, []]);
}
}
}
return decorators;
}
try {
// No preprocessing needed - parse content directly
const processedContent = content;
// Parse with Babel
const ast = parser.parse(processedContent, {
sourceType: 'module',
attachComment: true, // Attach comments to AST nodes for /** @decorator */ detection
plugins: [
'jsx',
'typescript',
'classProperties',
'classPrivateProperties',
'classPrivateMethods',
['decorators', { decoratorsBeforeExport: true }],
'dynamicImport',
'exportDefaultFrom',
'exportNamespaceFrom',
'asyncGenerators',
'objectRestSpread',
'optionalCatchBinding',
'optionalChaining',
'nullishCoalescingOperator',
'classStaticBlock'
]
});
// First pass: Check for classes and track top-level statements
ast.program.body.forEach(node => {
if (t.isClassDeclaration(node) || (t.isExportNamedDeclaration(node) && t.isClassDeclaration(node.declaration))) {
hasES6Class = true;
}
});
// Traverse AST
traverse(ast, {
// Class declarations
ClassDeclaration(path) {
const className = path.node.id.name;
// Extract both ES decorators and JSDoc decorators, merge them
const esDecorators = extractDecorators(path.node.decorators);
const jsdocDecorators = extractJSDocDecorators(path.node.leadingComments);
const allDecorators = [...esDecorators, ...jsdocDecorators];
const classInfo = {
name: className,
extends: path.node.superClass ? path.node.superClass.name : null,
public_instance_methods: {},
public_static_methods: {},
properties: {},
staticProperties: {},
decorators: allDecorators
};
// Extract methods and properties
path.node.body.body.forEach(member => {
if (t.isClassMethod(member)) {
// Check for @decorator in JSDoc comments on the method
let hasDecoratorComment = false;
if (member.leadingComments) {
hasDecoratorComment = member.leadingComments.some(comment => {
if (comment.type !== 'CommentBlock') return false;
return hasDecoratorTag(comment.value);
});
}
// Determine visibility: private if starts with #, otherwise public
const methodName = member.key.name || (t.isPrivateName(member.key) ? '#' + member.key.id.name : 'unknown');
const visibility = methodName.startsWith('#') ? 'private' : 'public';
const methodInfo = {
// PHP-compatible fields
name: methodName,
static: member.static,
visibility: visibility,
line: member.loc ? member.loc.start.line : null,
// JavaScript-specific fields (keep existing)
params: member.params.map(p => {
if (t.isIdentifier(p)) return p.name;
if (t.isRestElement(p) && t.isIdentifier(p.argument)) return '...' + p.argument.name;
if (t.isObjectPattern(p)) return '{object}';
if (t.isArrayPattern(p)) return '[array]';
return p.type;
}),
// PHP-compatible parameters structure
parameters: member.params.map(p => {
const paramInfo = {};
if (t.isIdentifier(p)) {
paramInfo.name = p.name;
} else if (t.isRestElement(p) && t.isIdentifier(p.argument)) {
paramInfo.name = '...' + p.argument.name;
} else if (t.isObjectPattern(p)) {
paramInfo.name = '{object}';
} else if (t.isArrayPattern(p)) {
paramInfo.name = '[array]';
} else {
paramInfo.name = p.type;
}
return paramInfo;
}),
async: member.async,
generator: member.generator,
kind: member.kind,
decorators: extractDecorators(member.decorators),
isDecoratorFunction: hasDecoratorComment
};
if (member.static) {
classInfo.public_static_methods[member.key.name] = methodInfo;
// Track static methods that are decorator functions
if (hasDecoratorComment) {
if (!result.functionsWithDecorators[className]) {
result.functionsWithDecorators[className] = {};
}
result.functionsWithDecorators[className][member.key.name] = {
decorators: [['decorator', []]],
line: member.loc ? member.loc.start.line : null
};
}
} else {
classInfo.public_instance_methods[member.key.name] = methodInfo;
}
} else if (t.isClassProperty(member) || t.isClassPrivateProperty(member)) {
const propName = t.isIdentifier(member.key) ? member.key.name :
t.isPrivateName(member.key) ? '#' + member.key.id.name :
'unknown';
const propInfo = {
name: propName,
static: member.static,
value: member.value ? getValueType(member.value) : null,
decorators: extractDecorators(member.decorators)
};
if (member.static) {
classInfo.staticProperties[propName] = propInfo;
} else {
classInfo.properties[propName] = propInfo;
}
}
});
result.classes[className] = classInfo;
},
// Function declarations
FunctionDeclaration(path) {
if (path.node.id) {
const funcName = path.node.id.name;
// Add to global functions list
result.globalFunctions.push(funcName);
// Check if it has decorators (either @decorator syntax or /** @decorator */ comment)
const decorators = extractDecorators(path.node.decorators);
// Check for @decorator in JSDoc comments
let hasDecoratorComment = false;
if (path.node.leadingComments) {
hasDecoratorComment = path.node.leadingComments.some(comment => {
if (comment.type !== 'CommentBlock') return false;
// Check if JSDoc contains @decorator tag
return hasDecoratorTag(comment.value);
});
}
if (decorators || hasDecoratorComment) {
result.functionsWithDecorators[funcName] = {
decorators: hasDecoratorComment ? [['decorator', []]] : decorators,
line: path.node.loc ? path.node.loc.start.line : null
};
}
result.functions[funcName] = {
name: funcName,
params: path.node.params.map(p => {
if (t.isIdentifier(p)) return p.name;
if (t.isRestElement(p) && t.isIdentifier(p.argument)) return '...' + p.argument.name;
if (t.isObjectPattern(p)) return '{object}';
if (t.isArrayPattern(p)) return '[array]';
return p.type;
}),
async: path.node.async,
generator: path.node.generator,
decorators: decorators
};
}
},
// Variable declarations (check for function expressions)
VariableDeclaration(path) {
// Only check top-level variables (directly in Program body)
if (path.parent && path.parent.type === 'Program') {
path.node.declarations.forEach(decl => {
if (t.isIdentifier(decl.id)) {
const varName = decl.id.name;
// Check if it's a function expression
if (decl.init && (t.isFunctionExpression(decl.init) || t.isArrowFunctionExpression(decl.init))) {
// Add to global functions list
result.globalFunctions.push(varName);
// Check for decorators (function expressions don't support decorators directly)
// But we still track them as functions
result.functions[varName] = {
name: varName,
params: decl.init.params ? decl.init.params.map(p => {
if (t.isIdentifier(p)) return p.name;
if (t.isRestElement(p) && t.isIdentifier(p.argument)) return '...' + p.argument.name;
if (t.isObjectPattern(p)) return '{object}';
if (t.isArrayPattern(p)) return '[array]';
return p.type;
}) : [],
async: decl.init.async || false,
generator: decl.init.generator || false,
decorators: null
};
} else if (path.node.kind === 'const' && decl.init && isStaticValue(decl.init)) {
// Const with static value - track it
result.globalConstants.push(varName);
} else if (!hasES6Class) {
// Non-allowed variable in a file without classes
// (not a function, not a const with static value)
codeOutsideAllowed.push({
line: path.node.loc ? path.node.loc.start.line : null,
code: getCodeSnippet(decl)
});
}
}
});
}
},
// Check for module.exports
AssignmentExpression(path) {
const left = path.node.left;
if (t.isMemberExpression(left)) {
if ((t.isIdentifier(left.object) && left.object.name === 'module') &&
(t.isIdentifier(left.property) && left.property.name === 'exports')) {
moduleExportsFound = path.node.loc ? path.node.loc.start.line : 1;
}
}
},
// Check for exports.something =
MemberExpression(path) {
if (path.parent && t.isAssignmentExpression(path.parent) && path.parent.left === path.node) {
if (t.isIdentifier(path.node.object) && path.node.object.name === 'exports') {
moduleExportsFound = path.node.loc ? path.node.loc.start.line : 1;
}
}
},
// Imports
ImportDeclaration(path) {
const importInfo = {
source: path.node.source.value,
specifiers: []
};
path.node.specifiers.forEach(spec => {
if (t.isImportDefaultSpecifier(spec)) {
importInfo.specifiers.push({
type: 'default',
local: spec.local.name
});
} else if (t.isImportSpecifier(spec)) {
importInfo.specifiers.push({
type: 'named',
imported: spec.imported.name,
local: spec.local.name
});
} else if (t.isImportNamespaceSpecifier(spec)) {
importInfo.specifiers.push({
type: 'namespace',
local: spec.local.name
});
}
});
result.imports.push(importInfo);
},
// Exports
ExportNamedDeclaration(path) {
if (path.node.declaration) {
if (t.isClassDeclaration(path.node.declaration) && path.node.declaration.id) {
result.exports[path.node.declaration.id.name] = 'class';
} else if (t.isFunctionDeclaration(path.node.declaration) && path.node.declaration.id) {
result.exports[path.node.declaration.id.name] = 'function';
} else if (t.isVariableDeclaration(path.node.declaration)) {
path.node.declaration.declarations.forEach(decl => {
if (t.isIdentifier(decl.id)) {
result.exports[decl.id.name] = 'variable';
}
});
}
}
// Handle export specifiers
if (path.node.specifiers) {
path.node.specifiers.forEach(spec => {
if (t.isExportSpecifier(spec)) {
result.exports[spec.exported.name] = 'named';
}
});
}
},
ExportDefaultDeclaration(path) {
result.exports.default = getExportType(path.node.declaration);
},
Program: {
exit(path) {
// After traversal, check structure violations
// Check for module.exports
if (moduleExportsFound) {
structureError(
'ModuleExportsFound',
'Module exports detected. JavaScript files are concatenated, use direct class references.',
moduleExportsFound,
null
);
}
// Check if this is a compiled/generated file (not originally a .js file)
// These files are generated from other sources (.jqhtml, etc) and should be exempt
const fileContent = fs.readFileSync(filePath, 'utf8');
const firstLine = fileContent.split('\n')[0];
if (firstLine && firstLine.includes('Compiled from:')) {
// This is a compiled file, skip all structure validation
return;
}
// TEMPORARY: Exempt specific utility files from strict validation
// TODO: Replace this with a proper @RULE comment-based system
const fileName = filePath.split('/').pop();
const exemptFiles = ['functions.js'];
if (exemptFiles.includes(fileName)) {
// Skip validation for these files temporarily
return;
}
// Check structure based on whether file has ES6 class
if (hasES6Class) {
// Files with classes should only have class and comments
let invalidCode = null;
path.node.body.forEach(node => {
// Allow comments (handled by parser)
if (t.isClassDeclaration(node)) {
// Class is allowed
return;
}
if (t.isExportNamedDeclaration(node) && t.isClassDeclaration(node.declaration)) {
// Exported class is allowed
return;
}
if (t.isExportDefaultDeclaration(node) && t.isClassExpression(node.declaration)) {
// Export default class is allowed
return;
}
if (t.isImportDeclaration(node)) {
// Imports are allowed (they're removed during bundling)
return;
}
// Anything else is not allowed
if (!invalidCode) {
invalidCode = {
line: node.loc ? node.loc.start.line : null,
code: getCodeSnippet(node)
};
}
});
if (invalidCode) {
structureError(
'CodeOutsideClass',
'JavaScript files with classes may only contain one class declaration and comments.',
invalidCode.line,
invalidCode.code
);
}
} else {
// Files without classes should only have functions and comments
let invalidCode = null;
path.node.body.forEach(node => {
// Allow function declarations
if (t.isFunctionDeclaration(node)) {
return;
}
// Allow variable declarations that are functions or static const values
if (t.isVariableDeclaration(node)) {
const allAllowed = node.declarations.every(decl => {
// Functions are always allowed
if (decl.init && (
t.isFunctionExpression(decl.init) ||
t.isArrowFunctionExpression(decl.init)
)) {
return true;
}
// Const declarations with static values are allowed
if (node.kind === 'const' && decl.init && isStaticValue(decl.init)) {
return true;
}
return false;
});
if (allAllowed) {
return;
}
}
// Allow imports (they're removed during bundling)
if (t.isImportDeclaration(node)) {
return;
}
// Allow exports that wrap functions
if (t.isExportNamedDeclaration(node)) {
if (t.isFunctionDeclaration(node.declaration)) {
return;
}
if (!node.declaration && node.specifiers) {
// Export of existing identifiers
return;
}
}
// Anything else is not allowed
if (!invalidCode) {
invalidCode = {
line: node.loc ? node.loc.start.line : null,
code: getCodeSnippet(node)
};
}
});
if (invalidCode) {
structureError(
'CodeOutsideAllowed',
'JavaScript files without classes may only contain function declarations, const variables with static values, and comments.',
invalidCode.line,
invalidCode.code
);
}
}
}
}
});
} catch (error) {
// Parse Babel error location if available
if (!error.loc && error.message) {
// Try to extract from message (e.g., "Unexpected token (10:5)")
const match = error.message.match(/\((\d+):(\d+)\)/);
if (match) {
error.loc = {
line: parseInt(match[1]),
column: parseInt(match[2])
};
}
}
outputError(error);
process.exit(1);
}
// Helper functions
function getValueType(node) {
if (t.isStringLiteral(node)) return `"${node.value}"`;
if (t.isNumericLiteral(node)) return node.value;
if (t.isBooleanLiteral(node)) return node.value;
if (t.isNullLiteral(node)) return null;
if (t.isIdentifier(node)) return node.name;
if (t.isArrayExpression(node)) return 'array';
if (t.isObjectExpression(node)) return 'object';
if (t.isFunctionExpression(node) || t.isArrowFunctionExpression(node)) return 'function';
return node.type;
}
function getExportType(node) {
if (t.isClassDeclaration(node)) return 'class';
if (t.isFunctionDeclaration(node)) return 'function';
if (t.isIdentifier(node)) return 'identifier';
if (t.isCallExpression(node)) return 'expression';
return node.type;
}
// Output result
if (jsonOutput) {
console.log(JSON.stringify({
status: 'success',
result: result,
file: filePath
}));
} else {
console.log(JSON.stringify(result, null, 2));
}

View File

@@ -0,0 +1,292 @@
#!/usr/bin/env node
const fs = require('fs');
const crypto = require('crypto');
const babel = require('@babel/core');
// Parse command line arguments
let filePath = null;
let target = 'modern';
let hashPath = null;
let jsonOutput = false;
// Process arguments
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i];
if (arg === '--json') {
jsonOutput = true;
} else if (!filePath) {
filePath = arg;
} else if (!target || target === 'modern') {
target = arg;
} else if (!hashPath) {
hashPath = arg;
}
}
// Default hashPath to filePath if not provided
if (!hashPath) {
hashPath = filePath;
}
// Error helper for JSON output
function outputError(error) {
if (jsonOutput) {
const errorObj = {
status: 'error',
error: {
type: error.constructor.name,
message: error.message,
line: error.loc ? error.loc.line : null,
column: error.loc ? error.loc.column : null,
file: filePath,
context: null,
suggestion: null
}
};
// Add suggestions for common errors
if (error.message.includes('Cannot find module')) {
errorObj.error.suggestion = 'Missing Babel dependencies. Run: npm install';
} else if (error.message.includes('Unexpected token')) {
errorObj.error.suggestion = 'Check JavaScript syntax in the source file';
} else if (error.message.includes('decorator')) {
errorObj.error.suggestion = 'Ensure decorators are properly formatted (e.g., @decorator before class/method)';
}
console.log(JSON.stringify(errorObj));
} else {
console.error(`Transformation error: ${error.message}`);
if (error.loc) {
console.error(` at line ${error.loc.line}, column ${error.loc.column}`);
}
// Provide helpful error messages
if (error.message.includes('Cannot find module')) {
console.error('\nMissing dependencies. Please run:');
console.error(`cd ${__dirname} && npm install`);
} else if (error.message.includes('Unexpected token')) {
console.error('\nSyntax error in source file. The file may contain invalid JavaScript.');
} else if (error.message.includes('decorator')) {
console.error('\nDecorator syntax error. Ensure decorators are properly formatted.');
}
}
}
if (!filePath) {
const error = new Error('No input file specified');
if (jsonOutput) {
console.log(JSON.stringify({
status: 'error',
error: {
type: 'ArgumentError',
message: error.message,
suggestion: 'Usage: node js-transformer.js [--json] <file-path> [target] [hash-path]'
}
}));
} else {
console.error('Usage: node js-transformer.js [--json] <file-path> [target] [hash-path]');
console.error('Targets: modern, es6, es5');
}
process.exit(1);
}
// Read file
let content;
try {
content = fs.readFileSync(filePath, 'utf8');
} catch (error) {
error.message = `Error reading file: ${error.message}`;
outputError(error);
process.exit(1);
}
/**
* Preprocessor to handle @decorator on standalone functions
* Converts @decorator to decorator comment when no ES6 classes are present
*/
function preprocessDecorators(content, filePath) {
// Check if file contains ES6 class declarations
// Using regex to avoid parsing errors from decorators
const es6ClassRegex = /^\s*class\s+[A-Z]\w*\s*(?:extends\s+\w+\s*)?\{/m;
const hasES6Class = es6ClassRegex.test(content);
if (hasES6Class) {
// File has ES6 classes, leave @decorator syntax unchanged
return content;
}
// No ES6 classes, convert @decorator to /** @decorator */
// Match @decorator at the start of a line (with optional whitespace)
// that appears before a function declaration
const decoratorRegex = /^(\s*)@decorator\s*\n(\s*(?:async\s+)?function\s+\w+)/gm;
const processed = content.replace(decoratorRegex, (match, indent, funcDecl) => {
return `${indent}/** @decorator */\n${funcDecl}`;
});
return processed;
}
// Preprocess content before transformation
content = preprocessDecorators(content, filePath);
// Generate file hash for prefixing (HARDCODED - NOT CONFIGURABLE)
// This prevents namespace collisions when files are concatenated in bundles
const fileHash = crypto.createHash('md5')
.update(hashPath)
.digest('hex')
.substring(0, 8);
// Target environment presets
const targetPresets = {
modern: {
targets: {
chrome: '90',
firefox: '88',
safari: '14',
edge: '90'
}
},
es6: {
targets: {
chrome: '60',
firefox: '60',
safari: '10.1',
edge: '15'
}
},
es5: {
targets: {
ie: '11'
}
}
};
// Create custom plugin to prefix generated WeakMap variables and Babel helper functions
// This plugin runs AFTER all other transformations to catch Babel-generated helpers
const prefixGeneratedVariables = function() {
return {
name: 'prefix-generated-variables',
post(file) {
// Run after all transformations are complete
const program = file.path;
// Track all top-level variables and functions that start with underscore
const generatedNames = new Set();
// First pass: collect all generated variable and function names at top level
for (const statement of program.node.body) {
if (statement.type === 'VariableDeclaration') {
for (const declarator of statement.declarations) {
const name = declarator.id?.name;
if (name && name.startsWith('_')) {
generatedNames.add(name);
}
}
} else if (statement.type === 'FunctionDeclaration') {
const name = statement.id?.name;
if (name && name.startsWith('_')) {
generatedNames.add(name);
}
}
}
// Second pass: rename all references
if (generatedNames.size > 0) {
program.traverse({
Identifier(idPath) {
if (generatedNames.has(idPath.node.name)) {
// Don't rename if it's already prefixed
if (!idPath.node.name.startsWith(`_${fileHash}`)) {
const newName = `_${fileHash}${idPath.node.name}`;
idPath.scope.rename(idPath.node.name, newName);
}
}
}
});
}
}
};
};
try {
// Configure Babel transformation
const result = babel.transformSync(content, {
filename: filePath,
sourceMaps: 'inline',
presets: [
['@babel/preset-env', targetPresets[target] || targetPresets.modern]
],
plugins: [
// Apply custom prefixing plugin first
prefixGeneratedVariables,
// Transform decorators (Stage 3 proposal)
// Note: We're NOT transforming private fields - native support only
['@babel/plugin-proposal-decorators', {
version: '2023-11',
// Ensure decorators are transpiled to compatible code
}],
// Transform class properties
'@babel/plugin-transform-class-properties',
// Transform optional chaining and nullish coalescing
'@babel/plugin-transform-optional-chaining',
'@babel/plugin-transform-nullish-coalescing-operator'
]
});
if (!result || !result.code) {
const error = new Error('Babel transformation produced no output');
outputError(error);
process.exit(1);
}
// Add comment header with file information
const header = `/* Transformed from: ${hashPath} (hash: ${fileHash}) */\n`;
const output = header + result.code;
// Output result
if (jsonOutput) {
console.log(JSON.stringify({
status: 'success',
result: output,
file: filePath,
hash: fileHash
}));
} else {
console.log(output);
}
} catch (error) {
// Parse Babel error location if available
if (error.loc) {
// Babel provides loc.line and loc.column
} else if (error.codeFrame) {
// Try to extract line/column from codeFrame
const lineMatch = error.codeFrame.match(/>\s*(\d+)\s*\|/);
const colMatch = error.codeFrame.match(/\n\s+\|\s+(\^+)/);
if (lineMatch) {
error.loc = {
line: parseInt(lineMatch[1]),
column: colMatch ? colMatch[1].indexOf('^') + 1 : 0
};
}
} else if (error.message) {
// Try to extract from message (e.g., "file.js: Unexpected token (10:5)")
const match = error.message.match(/\((\d+):(\d+)\)/);
if (match) {
error.loc = {
line: parseInt(match[1]),
column: parseInt(match[2])
};
}
}
outputError(error);
process.exit(1);
}

198
app/RSpade/Core/Js/Ajax.js Executable file
View File

@@ -0,0 +1,198 @@
// @FILE-SUBCLASS-01-EXCEPTION
/**
* Client-side Ajax class for making API calls to RSX controllers
*
* Mirrors the PHP Ajax::call (Ajax::internal) functionality for browser-side JavaScript
*/
class Ajax {
/**
* Make an AJAX call to an RSX controller action
*
* All calls are automatically batched using Rsx_Ajax_Batch unless
* window.rsxapp.ajax_disable_batching is true (for debugging).
*
* @param {string} controller - The controller class name (e.g., 'User_Controller')
* @param {string} action - The action method name (e.g., 'get_profile')
* @param {object} params - Parameters to send to the action
* @returns {Promise} - Resolves with the return value, rejects with error
*/
static async call(controller, action, params = {}) {
// Route through batch system
return Rsx_Ajax_Batch.call(controller, action, params);
}
/**
* DEPRECATED: Direct call implementation (preserved for reference)
* This is now handled by Rsx_Ajax_Batch
* @private
*/
static async _call_direct(controller, action, params = {}) {
// Build the endpoint URL
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}`, params);
}
return new Promise((resolve, reject) => {
$.ajax({
url: url,
method: 'POST',
data: params,
dataType: 'json',
__local_integration: true, // Bypass $.ajax override - this is the official Ajax endpoint pattern
success: (response) => {
// Handle console_debug messages if present
if (response.console_debug && Array.isArray(response.console_debug)) {
response.console_debug.forEach((msg) => {
// Messages must be structured as [channel, [arguments]]
if (!Array.isArray(msg) || msg.length !== 2) {
throw new Error('Invalid console_debug message format - expected [channel, [arguments]]');
}
const [channel, args] = msg;
// Output with channel as first argument, then spread the arguments
console.log(channel, ...args);
});
}
// Check if the response was successful
if (response.success === true) {
// Process the return value to instantiate any ORM models
const processedValue = Rsx_Js_Model._instantiate_models_recursive(response._ajax_return_value);
// Return the processed value
resolve(processedValue);
} 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 'response_auth_required':
console.error(
'The user is no longer authenticated, this is a placeholder for future code which handles this scenario.'
);
// Create an error object similar to PHP exceptions
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':
// Form validation errors
const form_error = new Error(reason);
form_error.type = 'form_error';
form_error.details = details;
reject(form_error);
break;
case 'response_fatal_error':
// Fatal errors
const fatal_error = new Error(reason);
fatal_error.type = 'fatal_error';
fatal_error.details = details;
// Log to server if browser error logging is enabled
Debugger.log_error({
message: `Ajax Fatal Error: ${reason}`,
type: 'ajax_fatal',
endpoint: url,
details: details,
});
reject(fatal_error);
break;
default:
// Unknown error type
const generic_error = new Error(reason);
generic_error.type = error_type;
generic_error.details = details;
reject(generic_error);
break;
}
}
},
error: (xhr, status, error) => {
// Handle network or server errors
let error_message = 'Network or server error';
if (xhr.responseJSON && xhr.responseJSON.message) {
error_message = xhr.responseJSON.message;
} else if (xhr.responseText) {
try {
const response = JSON.parse(xhr.responseText);
if (response.message) {
error_message = response.message;
}
} catch (e) {
// If response is not JSON, use the status text
error_message = `${status}: ${error}`;
}
} else {
error_message = `${status}: ${error}`;
}
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);
},
});
});
}
/**
* Parses an AJAX URL into controller and action
* @param {string} url - URL in format '/_ajax/Controller_Name/action_name'
* @returns {Object} Object with {controller: string, action: string}
* @throws {Error} If URL doesn't start with /_ajax or has invalid structure
*/
static ajax_url_to_controller_action(url) {
if (!url.startsWith('/_ajax')) {
throw new Error(`URL must start with /_ajax, 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 };
}
}

303
app/RSpade/Core/Js/Debugger.js Executable file
View File

@@ -0,0 +1,303 @@
/**
* Debugger class for console_debug and browser error logging
* Handles batched submission to server when configured
*/
class Debugger {
// Batching state for console_debug messages
static _console_batch = [];
static _console_timer = null;
static _console_batch_count = 0;
// Batching state for error messages
static _error_batch = [];
static _error_timer = null;
static _error_count = 0;
static _error_batch_count = 0;
// Constants
static DEBOUNCE_MS = 2000;
static MAX_ERRORS_PER_PAGE = 20;
static MAX_ERROR_BATCHES = 5;
// Store start time for benchmarking
static _start_time = null;
/**
* Initialize framework error handling
* Called during framework initialization
*/
static _on_framework_core_init() {
// Check if browser error logging is enabled
if (window.rsxapp && window.rsxapp.log_browser_errors) {
// Register global error handler
window.addEventListener('error', function (event) {
Debugger._handle_browser_error({
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error ? event.error.stack : null,
type: 'error',
});
});
// Register unhandled promise rejection handler
window.addEventListener('unhandledrejection', function (event) {
Debugger._handle_browser_error({
message: event.reason ? event.reason.message || String(event.reason) : 'Unhandled promise rejection',
stack: event.reason && event.reason.stack ? event.reason.stack : null,
type: 'unhandledrejection',
});
});
}
// Register ui refresh handler
Rsx.on('refresh', Debugger.on_refresh);
}
// In dev mode, some ui elements can be automatically applied to assist with development
static on_refresh() {
if (!Rsx.is_prod()) {
// Add an underline 2 px blue to all a tags with href === "#" using jquery
// Todo: maybe this should be a configurable debug option?
// $('a[href="#"]').css({
// 'border-bottom': '2px solid blue',
// 'text-decoration': 'none'
// });
}
}
/**
* JavaScript implementation of console_debug
* Mirrors PHP functionality with batching for Laravel log
*/
static console_debug(channel, ...values) {
// Check if console_debug is enabled
if (!window.rsxapp || !window.rsxapp.console_debug || !window.rsxapp.console_debug.enabled) {
return;
}
const config = window.rsxapp.console_debug;
// Normalize channel name
channel = String(channel)
.toUpperCase()
.replace(/[\[\]]/g, '');
// Apply filtering
if (config.filter_mode === 'specific') {
const specific = config.specific_channel;
if (specific) {
// Split comma-separated values and normalize
const channels = specific.split(',').map((c) => c.trim().toUpperCase());
if (!channels.includes(channel)) {
return;
}
}
} else if (config.filter_mode === 'whitelist') {
const whitelist = (config.filter_channels || []).map((c) => c.toUpperCase());
if (!whitelist.includes(channel)) {
return;
}
} else if (config.filter_mode === 'blacklist') {
const blacklist = (config.filter_channels || []).map((c) => c.toUpperCase());
if (blacklist.includes(channel)) {
return;
}
}
// Prepare the message
let message = {
channel: channel,
values: values,
timestamp: new Date().toISOString(),
};
// Add location if configured
if (config.include_location || config.include_backtrace) {
const error = new Error();
const stack = error.stack || '';
const stackLines = stack.split('\n');
if (config.include_location && stackLines.length > 2) {
// Skip Error line and this function
const callerLine = stackLines[2] || '';
const match = callerLine.match(/at\s+.*?\s+\((.*?):(\d+):(\d+)\)/) || callerLine.match(/at\s+(.*?):(\d+):(\d+)/);
if (match) {
message.location = `${match[1]}:${match[2]}`;
}
}
if (config.include_backtrace) {
// Include first 5 stack frames, skipping this function
message.backtrace = stackLines
.slice(2, 7)
.map((line) => line.trim())
.filter((line) => line);
}
}
// Output to browser console if enabled
if (config.outputs && config.outputs.browser) {
const prefix = config.include_benchmark ? `[${Debugger._get_time_prefix()}] ` : '';
const channelPrefix = `[${channel}]`;
// Use appropriate console method based on channel
let consoleMethod = 'log';
if (channel.includes('ERROR')) consoleMethod = 'error';
else if (channel.includes('WARN')) consoleMethod = 'warn';
else if (channel.includes('INFO')) consoleMethod = 'info';
console[consoleMethod](prefix + channelPrefix, ...values);
}
// Batch for Laravel log if enabled
if (config.outputs && config.outputs.laravel_log) {
Debugger._batch_console_message(message);
}
}
/**
* Log an error to the server
* Used manually or by Ajax error handling
*/
static log_error(error) {
// Check if browser error logging is enabled
if (!window.rsxapp || !window.rsxapp.log_browser_errors) {
return;
}
// Normalize error format
let errorData = {};
if (typeof error === 'string') {
errorData.message = error;
errorData.type = 'manual';
} else if (error instanceof Error) {
errorData.message = error.message;
errorData.stack = error.stack;
errorData.type = 'exception';
} else if (error && typeof error === 'object') {
errorData = error;
if (!errorData.type) {
errorData.type = 'manual';
}
}
Debugger._handle_browser_error(errorData);
}
/**
* Internal: Handle browser errors with batching
*/
static _handle_browser_error(errorData) {
// Check limits
if (Debugger._error_count >= Debugger.MAX_ERRORS_PER_PAGE) {
return;
}
if (Debugger._error_batch_count >= Debugger.MAX_ERROR_BATCHES) {
return;
}
Debugger._error_count++;
// Add metadata
errorData.url = window.location.href;
errorData.userAgent = navigator.userAgent;
errorData.timestamp = new Date().toISOString();
// Add to batch
Debugger._error_batch.push(errorData);
// Clear existing timer
if (Debugger._error_timer) {
clearTimeout(Debugger._error_timer);
}
// Set debounce timer
Debugger._error_timer = setTimeout(() => {
Debugger._flush_error_batch();
}, Debugger.DEBOUNCE_MS);
}
/**
* Internal: Batch console_debug messages for Laravel log
*/
static _batch_console_message(message) {
Debugger._console_batch.push(message);
// Clear existing timer
if (Debugger._console_timer) {
clearTimeout(Debugger._console_timer);
}
// Set debounce timer
Debugger._console_timer = setTimeout(() => {
Debugger._flush_console_batch();
}, Debugger.DEBOUNCE_MS);
}
/**
* Internal: Flush console_debug batch to server
*/
static async _flush_console_batch() {
if (Debugger._console_batch.length === 0) {
return;
}
const messages = Debugger._console_batch;
Debugger._console_batch = [];
Debugger._console_timer = null;
try {
await $.ajax({
url: '/_ajax/Debugger_Controller/log_console_messages',
method: 'POST',
data: JSON.stringify({ messages: messages }),
contentType: 'application/json',
dataType: 'json',
});
} catch (error) {
// Silently fail - don't create error loop
console.error('Failed to send console_debug messages to server:', error);
}
}
/**
* Internal: Flush error batch to server
*/
static async _flush_error_batch() {
if (Debugger._error_batch.length === 0) {
return;
}
const errors = Debugger._error_batch;
Debugger._error_batch = [];
Debugger._error_timer = null;
Debugger._error_batch_count++;
try {
await $.ajax({
url: '/_ajax/Debugger_Controller/log_browser_errors',
method: 'POST',
data: JSON.stringify({ errors: errors }),
contentType: 'application/json',
dataType: 'json',
});
} catch (error) {
// Silently fail - don't create error loop
console.error('Failed to send browser errors to server:', error);
}
}
/**
* Internal: Get time prefix for benchmarking
*/
static _get_time_prefix() {
const now = Date.now();
if (!Debugger._start_time) {
Debugger._start_time = now;
}
const elapsed = now - Debugger._start_time;
return (elapsed / 1000).toFixed(3) + 's';
}
}

339
app/RSpade/Core/Js/Form.js Executable file
View File

@@ -0,0 +1,339 @@
/**
* Form utilities for validation and error handling
*/
class Form {
/**
* Framework initialization hook to register jQuery plugin
* Creates $.fn.ajax_submit() for form elements
* @private
*/
static _on_framework_core_define(params = {}) {
$.fn.ajax_submit = function(options = {}) {
const $element = $(this);
if (!$element.is('form')) {
throw new Error('ajax_submit() can only be called on form elements');
}
const url = $element.attr('action');
if (!url) {
throw new Error('Form must have an action attribute');
}
const { controller, action } = Ajax.ajax_url_to_controller_action(url);
return Form.ajax_submit($element, controller, action, options);
};
}
/**
* Shows form validation errors
*
* REQUIRED HTML STRUCTURE:
* For inline field errors to display properly, form fields must follow this structure:
*
* <div class="form-group">
* <label class="form-label" for="field-name">Field Label</label>
* <input class="form-control" id="field-name" name="field-name" type="text">
* </div>
*
* Key requirements:
* - Wrap each field in a container with class "form-group" (or "form-check" / "input-group")
* - Input must have a "name" attribute matching the error key
* - Use "form-control" class on inputs for Bootstrap 5 styling
*
* Accepts three formats:
* - String: Single error shown as alert
* - Array of strings: Multiple errors shown as bulleted alert
* - Object: Field names mapped to errors, shown inline (unmatched shown as alert)
*
* @param {string} parent_selector - jQuery selector for parent element
* @param {string|Object|Array} errors - Error messages to display
* @returns {Promise} Promise that resolves when all animations complete
*/
static apply_form_errors(parent_selector, errors) {
console.error(errors);
const $parent = $(parent_selector);
// Reset the form errors before applying new ones
Form.reset_form_errors(parent_selector);
// Normalize input to standard format
const normalized = Form._normalize_errors(errors);
return new Promise((resolve) => {
let animations = [];
if (normalized.type === 'string') {
// Single error message
animations = Form._apply_general_errors($parent, normalized.data);
} else if (normalized.type === 'array') {
// Array of error messages
const deduplicated = Form._deduplicate_errors(normalized.data);
animations = Form._apply_general_errors($parent, deduplicated);
} else if (normalized.type === 'fields') {
// Field-specific errors
const result = Form._apply_field_errors($parent, normalized.data);
animations = result.animations;
// Show unmatched errors as general alert
const unmatched_deduplicated = Form._deduplicate_errors(result.unmatched);
if (Object.keys(unmatched_deduplicated).length > 0) {
const unmatched_animations = Form._apply_general_errors($parent, unmatched_deduplicated);
animations.push(...unmatched_animations);
}
}
// Resolve the promise once all animations are complete
Promise.all(animations).then(resolve);
});
}
/**
* Clears form validation errors and resets all form values to defaults
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
*/
static reset(form_selector) {
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
Form.reset_form_errors(form_selector);
$form.trigger('reset');
}
/**
* Serializes form data into key-value object
* Returns all input elements with name attributes as object properties
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
* @returns {Object} Form data as key-value pairs
*/
static serialize(form_selector) {
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
const data = {};
$form.serializeArray().forEach((item) => {
data[item.name] = item.value;
});
return data;
}
/**
* Submits form to RSX controller action via AJAX
* @param {string|jQuery} form_selector - jQuery selector or jQuery object for form element
* @param {string} controller - Controller class name (e.g., 'User_Controller')
* @param {string} action - Action method name (e.g., 'save_profile')
* @param {Object} options - Optional configuration {on_success: fn, on_error: fn}
* @returns {Promise} Promise that resolves with response data
*/
static async ajax_submit(form_selector, controller, action, options = {}) {
const $form = typeof form_selector === 'string' ? $(form_selector) : form_selector;
const form_data = Form.serialize($form);
Form.reset_form_errors(form_selector);
try {
const response = await Ajax.call(controller, action, form_data);
if (options.on_success) {
options.on_success(response);
}
return response;
} catch (error) {
if (error.type === 'form_error' && error.details) {
await Form.apply_form_errors(form_selector, error.details);
} else {
await Form.apply_form_errors(form_selector, error.message || 'An error occurred');
}
if (options.on_error) {
options.on_error(error);
}
throw error;
}
}
/**
* Removes form validation errors
* @param {string} parent_selector - jQuery selector for parent element
*/
static reset_form_errors(parent_selector) {
const $parent = $(parent_selector);
// Remove flash messages
$('.flash-messages').remove();
// Remove alert-danger messages
$parent.find('.alert-danger').remove();
// Remove validation error classes and text from form elements
$parent.find('.is-invalid').removeClass('is-invalid');
$parent.find('.invalid-feedback').remove();
}
// ------------------------
/**
* Normalizes error input into standard formats
* @param {string|Object|Array} errors - Raw error input
* @returns {Object} Normalized errors as {type: 'string'|'array'|'fields', data: ...}
* @private
*/
static _normalize_errors(errors) {
// Handle null/undefined
if (!errors) {
return { type: 'string', data: 'An error has occurred' };
}
// Handle string
if (typeof errors === 'string') {
return { type: 'string', data: errors };
}
// Handle array
if (Array.isArray(errors)) {
// Array of strings - general errors
if (errors.every((e) => typeof e === 'string')) {
return { type: 'array', data: errors };
}
// Array with object as first element - extract it
if (errors.length > 0 && typeof errors[0] === 'object') {
return Form._normalize_errors(errors[0]);
}
// Empty or mixed array
return { type: 'array', data: [] };
}
// Handle object - check for Laravel response wrapper
if (typeof errors === 'object') {
// Unwrap {errors: {...}} or {error: {...}}
const unwrapped = errors.errors || errors.error;
if (unwrapped) {
return Form._normalize_errors(unwrapped);
}
// Convert Laravel validator format {field: [msg1, msg2]} to {field: msg1}
const normalized = {};
for (const field in errors) {
if (errors.hasOwnProperty(field)) {
const value = errors[field];
if (Array.isArray(value) && value.length > 0) {
normalized[field] = value[0];
} else if (typeof value === 'string') {
normalized[field] = value;
} else {
normalized[field] = String(value);
}
}
}
return { type: 'fields', data: normalized };
}
// Final catch-all*
return { type: 'string', data: String(errors) };
}
/**
* Removes duplicate error messages from array or object values
* @param {Array|Object} errors - Errors to deduplicate
* @returns {Array|Object} Deduplicated errors
* @private
*/
static _deduplicate_errors(errors) {
if (Array.isArray(errors)) {
return [...new Set(errors)];
}
if (typeof errors === 'object') {
const seen = new Set();
const result = {};
for (const key in errors) {
const value = errors[key];
if (!seen.has(value)) {
seen.add(value);
result[key] = value;
}
}
return result;
}
return errors;
}
/**
* Applies field-specific validation errors to form inputs
* @param {jQuery} $parent - Parent element containing form
* @param {Object} field_errors - Object mapping field names to error messages
* @returns {Object} Object containing {animations: Array, unmatched: Object}
* @private
*/
static _apply_field_errors($parent, field_errors) {
const animations = [];
const unmatched = {};
for (const field_name in field_errors) {
const error_message = field_errors[field_name];
const $input = $parent.find(`[name="${field_name}"]`);
if (!$input.length) {
unmatched[field_name] = error_message;
continue;
}
const $error = $('<div class="invalid-feedback"></div>').html(error_message);
const $target = $input.closest('.form-group, .form-check, .input-group');
if (!$target.length) {
unmatched[field_name] = error_message;
continue;
}
$input.addClass('is-invalid');
$error.appendTo($target);
animations.push($error.hide().fadeIn(300).promise());
}
return { animations, unmatched };
}
/**
* Applies general error messages as alert box
* @param {jQuery} $parent - Parent element to prepend alert to
* @param {string|Array} messages - Error message(s) to display
* @returns {Array} Array of animation promises
* @private
*/
static _apply_general_errors($parent, messages) {
const animations = [];
if (typeof messages === 'string') {
// Single error - simple alert without list
const $alert = $('<div class="alert alert-danger" role="alert"></div>').text(messages);
animations.push($alert.hide().prependTo($parent).fadeIn(300).promise());
} else if (Array.isArray(messages) && messages.length > 0) {
// Multiple errors - bulleted list
const $alert = $('<div class="alert alert-danger" role="alert"><ul class="mb-0"></ul></div>');
const $list = $alert.find('ul');
messages.forEach((msg) => {
const text = (msg + '').trim() || 'An error has occurred';
$('<li></li>').html(text).appendTo($list);
});
animations.push($alert.hide().prependTo($parent).fadeIn(300).promise());
} else if (typeof messages === 'object' && !Array.isArray(messages)) {
// Object of unmatched field errors - convert to array
const error_list = Object.values(messages)
.map((v) => String(v).trim())
.filter((v) => v);
if (error_list.length > 0) {
return Form._apply_general_errors($parent, error_list);
}
}
return animations;
}
}

361
app/RSpade/Core/Js/Manifest.js Executable file
View File

@@ -0,0 +1,361 @@
/**
* Manifest - JavaScript class registry and metadata system
*
* This class maintains a registry of all JavaScript classes in the bundle,
* tracking their names and inheritance relationships. It provides utilities
* for working with class hierarchies and calling initialization methods.
*/
class Manifest {
/**
* Define classes in the manifest (framework internal)
* @param {Array} items - Array of class definitions [[Class, "ClassName", ParentClass, decorators], ...]
*/
static _define(items) {
// Initialize the classes object if not already defined
if (typeof Manifest._classes === 'undefined') {
Manifest._classes = {};
}
// Process each class definition
items.forEach((item) => {
let class_object = item[0];
let class_name = item[1];
let class_extends = item[2] || null;
let decorators = item[3] || null;
// Store the class information (using object to avoid duplicates)
Manifest._classes[class_name] = {
class: class_object,
name: class_name,
extends: class_extends,
decorators: decorators, // Store compact decorator data
};
// Add metadata to the class object itself
class_object._name = class_name;
class_object._extends = class_extends;
class_object._decorators = decorators;
});
// Build the subclass index after all classes are defined
Manifest._build_subclass_index();
}
/**
* Build an index of subclasses for efficient lookups
* This creates a mapping where each class name points to an array of all its subclasses
* @private
*/
static _build_subclass_index() {
// Initialize the subclass index
Manifest._subclass_index = {};
// Step through each class and walk up its parent chain
for (let class_name in Manifest._classes) {
const classdata = Manifest._classes[class_name];
let current_class_name = class_name;
let current_classdata = classdata;
// Walk up the parent chain until we reach the root
while (current_classdata) {
const extends_name = current_classdata.extends;
if (extends_name) {
// Initialize the parent's subclass array if needed
if (!Manifest._subclass_index[extends_name]) {
Manifest._subclass_index[extends_name] = [];
}
// Add this class to its parent's subclass list
if (!Manifest._subclass_index[extends_name].includes(class_name)) {
Manifest._subclass_index[extends_name].push(class_name);
}
// Move up to the parent's metadata (if it exists in manifest)
if (Manifest._classes[extends_name]) {
current_classdata = Manifest._classes[extends_name];
} else {
// Parent not in manifest (e.g., native JavaScript class), stop here
current_classdata = null;
}
} else {
// No parent, we've reached the root
current_classdata = null;
}
}
}
}
/**
* Get all classes that extend a given base class
* @param {Class|string} base_class - The base class (object or name string) to check for
* @returns {Array} Array of objects with {class_name, class_object} for classes that extend the base class
*/
static get_extending(base_class) {
if (!Manifest._classes) {
return [];
}
// Convert string to class object if needed
let base_class_object = base_class;
if (typeof base_class === 'string') {
base_class_object = Manifest.get_class_by_name(base_class);
if (!base_class_object) {
throw new Error(`Base class not found: ${base_class}`);
}
}
const classes = [];
for (let class_name in Manifest._classes) {
const classdata = Manifest._classes[class_name];
if (Manifest.js_is_subclass_of(classdata.class, base_class_object)) {
classes.push({
class_name: class_name,
class_object: classdata.class,
});
}
}
// Sort alphabetically by class name to ensure deterministic behavior and prevent race condition bugs
classes.sort((a, b) => a.class_name.localeCompare(b.class_name));
return classes;
}
/**
* Check if a class is a subclass of another class
* Matches PHP Manifest::js_is_subclass_of() signature and behavior
* @param {Class|string} subclass - The child class (object or name) to check
* @param {Class|string} superclass - The parent class (object or name) to check against
* @returns {boolean} True if subclass extends superclass (directly or indirectly)
*/
static js_is_subclass_of(subclass, superclass) {
// Convert string names to class objects
let subclass_object = subclass;
if (typeof subclass === 'string') {
subclass_object = Manifest.get_class_by_name(subclass);
if (!subclass_object) {
// Can't resolve subclass - return false per spec
return false;
}
}
let superclass_object = superclass;
if (typeof superclass === 'string') {
superclass_object = Manifest.get_class_by_name(superclass);
if (!superclass_object) {
// Can't resolve superclass - fail loud per spec
throw new Error(`Superclass not found in manifest: ${superclass}`);
}
}
// Classes are not subclasses of themselves
if (subclass_object === superclass_object) {
return false;
}
// Walk up the inheritance chain
let current_class = subclass_object;
while (current_class) {
if (current_class === superclass_object) {
return true;
}
// Move up to parent class
if (current_class._extends) {
// _extends may be a string or class reference
if (typeof current_class._extends === 'string') {
current_class = Manifest.get_class_by_name(current_class._extends);
} else {
current_class = current_class._extends;
}
} else {
current_class = null;
}
}
return false;
}
/**
* Get a class by its name
* @param {string} class_name - The name of the class
* @returns {Class|null} The class object or null if not found
*/
static get_class_by_name(class_name) {
if (!Manifest._classes || !Manifest._classes[class_name]) {
return null;
}
return Manifest._classes[class_name].class;
}
/**
* Get all registered classes
* @returns {Array} Array of objects with {class_name, class_object, extends}
*/
static get_all_classes() {
if (!Manifest._classes) {
return [];
}
const results = [];
for (let class_name in Manifest._classes) {
const classdata = Manifest._classes[class_name];
results.push({
class_name: classdata.name,
class_object: classdata.class,
extends: classdata.extends,
});
}
// Sort alphabetically by class name to ensure deterministic behavior and prevent race condition bugs
results.sort((a, b) => a.class_name.localeCompare(b.class_name));
return results;
}
/**
* Get the build key from the application configuration
* @returns {string} The build key or "NOBUILD" if not available
*/
static build_key() {
if (window.rsxapp && window.rsxapp.build_key) {
return window.rsxapp.build_key;
}
return 'NOBUILD';
}
/**
* Get decorators for a specific class and method
* @param {string|Class} class_name - The class name or class object
* @param {string} method_name - The method name
* @returns {Array|null} Array of decorator objects or null if none found
*/
static get_decorators(class_name, method_name) {
// Convert class object to name if needed
if (typeof class_name !== 'string') {
class_name = class_name._name || class_name.name;
}
const class_info = Manifest._classes[class_name];
if (!class_info || !class_info.decorators || !class_info.decorators[method_name]) {
return null;
}
// Transform compact format to object format
return Manifest._transform_decorators(class_info.decorators[method_name]);
}
/**
* Get all methods with decorators for a class
* @param {string|Class} class_name - The class name or class object
* @returns {Object} Object with method names as keys and decorator arrays as values
*/
static get_all_decorators(class_name) {
// Convert class object to name if needed
if (typeof class_name !== 'string') {
class_name = class_name._name || class_name.name;
}
const class_info = Manifest._classes[class_name];
if (!class_info || !class_info.decorators) {
return {};
}
// Transform all decorators from compact to object format
const result = {};
for (let method_name in class_info.decorators) {
result[method_name] = Manifest._transform_decorators(class_info.decorators[method_name]);
}
return result;
}
/**
* Transform compact decorator format to object format
* @param {Array} compact_decorators - Array of [name, [args]] tuples
* @returns {Array} Array of decorator objects with name and arguments properties
* @private
*/
static _transform_decorators(compact_decorators) {
if (!Array.isArray(compact_decorators)) {
return [];
}
return compact_decorators.map(decorator => {
if (Array.isArray(decorator) && decorator.length >= 2) {
return {
name: decorator[0],
arguments: decorator[1] || []
};
}
// Handle malformed decorator data
return {
name: 'unknown',
arguments: []
};
});
}
/**
* Check if a method has a specific decorator
* @param {string|Class} class_name - The class name or class object
* @param {string} method_name - The method name
* @param {string} decorator_name - The decorator name to check for
* @returns {boolean} True if the method has the decorator
*/
static has_decorator(class_name, method_name, decorator_name) {
const decorators = Manifest.get_decorators(class_name, method_name);
if (!decorators) {
return false;
}
return decorators.some(d => d.name === decorator_name);
}
/**
* Get all subclasses of a given class using the pre-built index
* This is the JavaScript equivalent of PHP's Manifest::js_get_subclasses_of()
* @param {Class|string} base_class - The base class (object or name string) to get subclasses of
* @returns {Array<Class>} Array of actual class objects that are subclasses of the base class
*/
static js_get_subclasses_of(base_class) {
// Initialize index if needed
if (!Manifest._subclass_index) {
Manifest._build_subclass_index();
}
// Convert class object to name if needed
let base_class_name = base_class;
if (typeof base_class !== 'string') {
base_class_name = base_class._name || base_class.name;
}
// Check if the base class exists
if (!Manifest._classes[base_class_name]) {
// Base class not in manifest - return empty array
return [];
}
// Get subclass names from the index
const subclass_names = Manifest._subclass_index[base_class_name] || [];
// Convert names to actual class objects
const subclass_objects = [];
for (let subclass_name of subclass_names) {
const classdata = Manifest._classes[subclass_name];
subclass_objects.push(classdata.class);
}
// Sort by class name for deterministic behavior
subclass_objects.sort((a, b) => {
const name_a = a._name || a.name;
const name_b = b._name || b.name;
return name_a.localeCompare(name_b);
});
return subclass_objects;
}
}
// RSX manifest automatically makes classes global - no manual assignment needed

127
app/RSpade/Core/Js/Mutex.js Executable file
View File

@@ -0,0 +1,127 @@
/**
* Mutex decorator for exclusive method execution
*
* Without arguments: Per-instance locking (each object has its own lock per method)
* @mutex
* async my_method() { ... }
*
* With ID argument: Global locking by ID (all instances share the lock)
* @mutex('operation_name')
* async my_method() { ... }
*
* @decorator
* @param {string} [global_id] - Optional global mutex ID for cross-instance locking
*/
function mutex(global_id) {
// Storage (using IIFEs to keep WeakMap/Map in closure scope)
const instance_mutexes = (function() {
if (!mutex._instance_storage) {
mutex._instance_storage = new WeakMap();
}
return mutex._instance_storage;
})();
const global_mutexes = (function() {
if (!mutex._global_storage) {
mutex._global_storage = new Map();
}
return mutex._global_storage;
})();
/**
* Get or create a mutex for a specific instance and method
*/
function get_instance_mutex(instance, method_name) {
let instance_locks = instance_mutexes.get(instance);
if (!instance_locks) {
instance_locks = new Map();
instance_mutexes.set(instance, instance_locks);
}
let lock_state = instance_locks.get(method_name);
if (!lock_state) {
lock_state = { active: false, queue: [] };
instance_locks.set(method_name, lock_state);
}
return lock_state;
}
/**
* Get or create a global mutex by ID
*/
function get_global_mutex(id) {
let lock_state = global_mutexes.get(id);
if (!lock_state) {
lock_state = { active: false, queue: [] };
global_mutexes.set(id, lock_state);
}
return lock_state;
}
/**
* Execute the next queued operation for a mutex
*/
function schedule_next(lock_state) {
if (lock_state.active || lock_state.queue.length === 0) {
return;
}
const { fn, resolve, reject } = lock_state.queue.shift();
lock_state.active = true;
Promise.resolve()
.then(fn)
.then(resolve, reject)
.finally(() => {
lock_state.active = false;
schedule_next(lock_state);
});
}
/**
* Acquire a mutex lock and execute callback
*/
function acquire_lock(lock_state, fn) {
return new Promise((resolve, reject) => {
lock_state.queue.push({ fn, resolve, reject });
schedule_next(lock_state);
});
}
// If called with an ID argument: @mutex('id')
if (typeof global_id === 'string') {
return function(target, key, descriptor) {
const original_method = descriptor.value;
if (typeof original_method !== 'function') {
throw new Error(`@mutex can only be applied to methods (tried to apply to ${key})`);
}
descriptor.value = function(...args) {
const lock_state = get_global_mutex(global_id);
return acquire_lock(lock_state, () => original_method.apply(this, args));
};
return descriptor;
};
}
// If called without arguments: @mutex (target is the first argument)
const target = global_id; // In this case, first arg is target
const key = arguments[1];
const descriptor = arguments[2];
const original_method = descriptor.value;
if (typeof original_method !== 'function') {
throw new Error(`@mutex can only be applied to methods (tried to apply to ${key})`);
}
descriptor.value = function(...args) {
const lock_state = get_instance_mutex(this, key);
return acquire_lock(lock_state, () => original_method.apply(this, args));
};
return descriptor;
}

View File

@@ -0,0 +1,123 @@
/**
* ReadWriteLock implementation for RSpade framework
* Provides exclusive (write) and shared (read) locking mechanisms for asynchronous operations
*/
class ReadWriteLock {
static #locks = new Map();
/**
* Get or create a lock object for a given name
* @private
*/
static #get_lock(name) {
let s = this.#locks.get(name);
if (!s) {
s = { readers: 0, writer_active: false, reader_q: [], writer_q: [] };
this.#locks.set(name, s);
}
return s;
}
/**
* Schedule the next operation for a lock
* @private
*/
static #schedule(name) {
const s = this.#get_lock(name);
if (s.writer_active || s.readers > 0) return;
// run one writer if queued
if (s.writer_q.length > 0) {
const { cb, resolve, reject } = s.writer_q.shift();
s.writer_active = true;
Promise.resolve()
.then(cb)
.then(resolve, reject)
.finally(() => {
s.writer_active = false;
this.#schedule(name);
});
return;
}
// otherwise run all queued readers in parallel
if (s.reader_q.length > 0) {
const batch = s.reader_q.splice(0);
s.readers += batch.length;
for (const { cb, resolve, reject } of batch) {
Promise.resolve()
.then(cb)
.then(resolve, reject)
.finally(() => {
s.readers -= 1;
if (s.readers === 0) this.#schedule(name);
});
}
}
}
/**
* Acquire an exclusive mutex lock by name.
* Only one writer runs at a time; blocks readers until finished.
* @param {string} name
* @param {() => any|Promise<any>} cb
* @returns {Promise<any>}
*/
static acquire(name, cb) {
return new Promise((resolve, reject) => {
const s = this.#get_lock(name);
s.writer_q.push({ cb, resolve, reject });
this.#schedule(name);
});
}
/**
* Acquire a shared read lock by name.
* Multiple readers can run in parallel; blocks when writer is active.
* @param {string} name
* @param {() => any|Promise<any>} cb
* @returns {Promise<any>}
*/
static acquire_read(name, cb) {
return new Promise((resolve, reject) => {
const s = this.#get_lock(name);
if (s.writer_active || s.writer_q.length > 0) {
s.reader_q.push({ cb, resolve, reject });
return this.#schedule(name);
}
s.readers += 1;
Promise.resolve()
.then(cb)
.then(resolve, reject)
.finally(() => {
s.readers -= 1;
if (s.readers === 0) this.#schedule(name);
});
});
}
/**
* Force-unlock a mutex (use with caution).
* Completely removes the lock state, potentially breaking waiting operations.
* @param {string} name
*/
static force_unlock(name) {
this.#locks.delete(name);
}
/**
* Get information about pending operations on a mutex.
* @param {string} name
* @returns {{readers: number, writer_active: boolean, reader_q: number, writer_q: number}}
*/
static pending(name) {
const s = this.#locks.get(name);
if (!s) return { readers: 0, writer_active: false, reader_q: 0, writer_q: 0 };
return {
readers: s.readers,
writer_active: s.writer_active,
reader_q: s.reader_q.length,
writer_q: s.writer_q.length
};
}
}

326
app/RSpade/Core/Js/Rsx.js Executable file
View File

@@ -0,0 +1,326 @@
// @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;
// 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');
}
// 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 = {};
/**
* 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];
}
}
}
/**
* Create a route proxy for type-safe URL generation
*
* This method creates a route proxy that can generate URLs for a specific controller action.
* The proxy ensures all required route parameters are provided and handles extra parameters
* as query string values.
*
* Usage examples:
* ```javascript
* // Simple route without parameters (defaults to 'index' action)
* const url = Rsx.Route('Frontend_Index_Controller').url();
* // Returns: /dashboard
*
* // Route with explicit action
* const url = Rsx.Route('Frontend_Index_Controller', 'index').url();
* // Returns: /dashboard
*
* // Route with required parameter
* const url = Rsx.Route('Frontend_Client_View_Controller').url({id: 'C001'});
* // Returns: /clients/view/C001
*
* // Route with required and query parameters
* const url = Rsx.Route('Frontend_Client_View_Controller').url({
* id: 'C001',
* tab: 'history'
* });
* // Returns: /clients/view/C001?tab=history
*
* // Generate absolute URL
* const absolute = Rsx.Route('Frontend_Index_Controller').absolute_url();
* // Returns: https://example.com/dashboard
*
* // Navigate to route
* Rsx.Route('Frontend_Index_Controller').navigate();
* // Redirects browser to /dashboard
*
* // Check if route is current
* if (Rsx.Route('Frontend_Index_Controller').is_current()) {
* // This is the currently executing route
* }
* ```
*
* @param {string} class_name The controller class name (e.g., 'User_Controller')
* @param {string} [action_name='index'] The action/method name (defaults to 'index')
* @returns {Rsx_Route_Proxy} Route proxy instance for URL generation
* @throws {Error} If route not found
*/
static Route(class_name, action_name = 'index') {
// Check if route exists
if (!Rsx._routes[class_name]) {
throw new Error(`Class ${class_name} not found in routes`);
}
if (!Rsx._routes[class_name][action_name]) {
throw new Error(`Method ${action_name} not found in class ${class_name}`);
}
const pattern = Rsx._routes[class_name][action_name];
return new Rsx_Route_Proxy(class_name, action_name, pattern);
}
/**
* 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;
// 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');
// 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;
}
}

View File

@@ -0,0 +1,348 @@
// @FILE-SUBCLASS-01-EXCEPTION
/**
* Rsx_Ajax_Batch - Batches multiple Ajax calls into single HTTP request
*
* Inspired by RS3's batch API system. All Ajax.call() requests are batched
* together into a single POST to /_ajax/_batch. This dramatically reduces
* HTTP requests from N to 1-3 per page.
*
* Key behaviors:
* - Batches up to 20 calls, then flushes immediately (MAX_BATCH_SIZE)
* - New calls after flush go into next batch
* - Uses setTimeout(0) debounce when under batch size limit (DEBOUNCE_MS)
* - Deduplicates identical calls (same controller/action/params)
* - Returns cached results for duplicate calls
* - Can be disabled via window.rsxapp.ajax_disable_batching for debugging
*/
class Rsx_Ajax_Batch {
/**
* Initialize batch system
* Called automatically when class is loaded
*/
static init() {
// Queue of pending calls waiting to be batched
Rsx_Ajax_Batch._pending_calls = {};
// Timer for batching flush
Rsx_Ajax_Batch._flush_timeout = null;
// Call counter for generating unique call IDs
Rsx_Ajax_Batch._call_counter = 0;
// Maximum batch size before forcing immediate flush
Rsx_Ajax_Batch.MAX_BATCH_SIZE = 20;
// Debounce time in milliseconds
Rsx_Ajax_Batch.DEBOUNCE_MS = 0;
}
/**
* Queue an Ajax call for batching
*
* @param {string} controller - Controller class name
* @param {string} action - Action method name
* @param {object} params - Parameters to send
* @returns {Promise} - Resolves with return value or rejects with error
*/
static call(controller, action, params = {}) {
// Check if batching is disabled for debugging
if (window.rsxapp && window.rsxapp.ajax_disable_batching) {
// Make individual request immediately
return Rsx_Ajax_Batch._make_individual_request(controller, action, params);
}
return new Promise((resolve, reject) => {
// Generate call key for deduplication
// Same controller + action + params = same call
const call_key = Rsx_Ajax_Batch._generate_call_key(controller, action, params);
// Check if this exact call is already pending
if (Rsx_Ajax_Batch._pending_calls[call_key]) {
const existing_call = Rsx_Ajax_Batch._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 = Rsx_Ajax_Batch._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
Rsx_Ajax_Batch._pending_calls[call_key] = pending_call;
// Count pending calls
const pending_count = Object.keys(Rsx_Ajax_Batch._pending_calls).filter(
(key) => !Rsx_Ajax_Batch._pending_calls[key].is_complete
).length;
// If we've hit the batch size limit, flush immediately
if (pending_count >= Rsx_Ajax_Batch.MAX_BATCH_SIZE) {
clearTimeout(Rsx_Ajax_Batch._flush_timeout);
Rsx_Ajax_Batch._flush_timeout = null;
Rsx_Ajax_Batch._flush_pending_calls();
} else {
// Schedule batch flush with debounce
clearTimeout(Rsx_Ajax_Batch._flush_timeout);
Rsx_Ajax_Batch._flush_timeout = setTimeout(() => {
Rsx_Ajax_Batch._flush_pending_calls();
}, Rsx_Ajax_Batch.DEBOUNCE_MS);
}
});
}
/**
* 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 Rsx_Ajax_Batch._pending_calls) {
const pending_call = Rsx_Ajax_Batch._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
// Process the return value to instantiate any ORM models
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';
const reason = call_response.reason || 'Unknown error occurred';
const details = call_response.details || {};
const error = new Error(reason);
error.type = error_type;
error.details = 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 = Rsx_Ajax_Batch._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);
}
}
/**
* Make an individual Ajax request (when batching is disabled)
* @private
*/
static async _make_individual_request(controller, action, params) {
const url = `/_ajax/${controller}/${action}`;
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,
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');
}
const [channel, args] = msg;
console.log(channel, ...args);
});
}
if (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(response._ajax_return_value);
resolve(processed_value);
} else {
const error_type = response.error_type || 'unknown_error';
const reason = response.reason || 'Unknown error occurred';
const details = response.details || {};
const error = new Error(reason);
error.type = error_type;
error.details = details;
reject(error);
}
},
error: (xhr, status, error) => {
const error_message = Rsx_Ajax_Batch._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;
reject(network_error);
},
});
});
}
/**
* 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'}`;
}
/**
* Auto-initialize static properties when class is first loaded
* Called by on_core_define lifecycle hook
*/
static on_core_define() {
Rsx_Ajax_Batch.init();
}
}

View File

@@ -0,0 +1,108 @@
/**
* Rsx_Behaviors - Core Framework User Experience Enhancements
*
* This class provides automatic quality-of-life behaviors that improve the default
* browser experience for RSX applications. These behaviors are transparent to
* application developers and run automatically on framework initialization.
*
* These behaviors use jQuery event delegation to handle both existing and dynamically
* added content. They are implemented with low priority to allow application code to
* override default behaviors when needed.
*
* @internal Framework use only - not part of public API
*/
class Rsx_Behaviors {
static _on_framework_core_init() {
Rsx_Behaviors._init_ignore_invalid_anchor_links();
Rsx_Behaviors._trim_copied_text();
}
/**
* - Anchor link handling: Prevents broken "#" links from causing page jumps or URL changes
* - Ignores "#" (empty hash) to prevent scroll-to-top behavior
* - Ignores "#placeholder*" links used as route placeholders during development
* - Validates anchor targets exist before allowing navigation
* - Preserves normal anchor behavior when targets exist
*/
static _init_ignore_invalid_anchor_links() {
return; // disabled for now - make this into a configurable option
// Use event delegation on document to handle all current and future anchor clicks
// Use mousedown instead of click to run before most application handlers
$(document).on('mousedown', 'a[href^="#"]', function (e) {
const $link = $(this);
const href = $link.attr('href');
// Check if another handler has already prevented default
if (e.isDefaultPrevented()) {
return;
}
// Allow data-rsx-allow-hash attribute to bypass this behavior
if ($link.data('rsx-allow-hash')) {
return;
}
// Handle empty hash - prevent scroll to top
if (href === '#') {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
// Handle placeholder links used during development
if (href.startsWith('#placeholder')) {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
// For other hash links, check if target exists
const targetId = href.substring(1);
if (targetId) {
// Check for element with matching ID or name attribute
const targetExists = document.getElementById(targetId) !== null || document.querySelector(`[name="${targetId}"]`) !== null;
if (!targetExists) {
// Target doesn't exist - prevent navigation
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
// Target exists - allow normal anchor behavior
}
});
}
/**
* - Copy text trimming: Automatically removes leading/trailing whitespace from copied text
* - Hold Shift to preserve whitespace
* - Skips trimming in code blocks, textareas, and contenteditable elements
*/
static _trim_copied_text() {
document.addEventListener('copy', function (event) {
// Don't trim if user is holding Shift (allows copying with whitespace if needed)
if (event.shiftKey) return;
let selection = window.getSelection();
let selected_text = selection.toString();
// Don't trim if selection is empty
if (!selected_text) return;
// Don't trim if copying from code blocks, textareas, or content-editable (preserve formatting)
let container = selection.getRangeAt(0).commonAncestorContainer;
if (container.nodeType === 3) container = container.parentNode; // Text node to element
if (container.closest('pre, code, .code-block, textarea, [contenteditable="true"]')) return;
let trimmed_text = selected_text.trim();
// Only modify if there's actually whitespace to trim
if (trimmed_text !== selected_text && trimmed_text.length > 0) {
event.preventDefault();
event.clipboardData.setData('text/plain', trimmed_text);
console.log('Copy: trimmed whitespace from selection');
}
});
}
}

210
app/RSpade/Core/Js/Rsx_Cache.js Executable file
View File

@@ -0,0 +1,210 @@
// Simple key value cache. Can only store 5000 entries, will reset after 5000 entries.
// Todo: keep local cache concept the same, replace global cache concept with the nov 2019 version of
// session cache. Use a session key & build key to track cache keys so cached values only last until user logs out.
// review session code to ensure that session key *always* rotates on logout. Make session id a protected value.
class Rsx_Cache {
static on_core_define() {
Core_Cache._caches = {
global: {},
instance: {},
};
Core_Cache._caches_set = 0;
}
// Alias for get_instance
static get(key) {
return Rsx_Cache.get_instance(key);
}
// Returns from the pool of cached data for this 'instance'. An instance
// in this case is a virtual page load / navigation in the SPA. Call Main.lib.reset() to reset.
// Returns null on failure
static get_instance(key) {
if (Main.debug('no_api_cache')) {
return null;
}
let key_encoded = Rsx_Cache._encodekey(key);
if (typeof Core_Cache._caches.instance[key_encoded] != undef) {
return JSON.parse(Core_Cache._caches.instance[key_encoded]);
}
return null;
}
// Returns null on failure
// Returns a cached value from global cache (unique to page load, survives reset())
static get_global(key) {
if (Main.debug('no_api_cache')) {
return null;
}
let key_encoded = Rsx_Cache._encodekey(key);
if (typeof Core_Cache._caches.global[key_encoded] != undef) {
return JSON.parse(Core_Cache._caches.global[key_encoded]);
}
return null;
}
// Sets a value in instance and global cache (not shared between browser tabs)
static set(key, value) {
if (Main.debug('no_api_cache')) {
return;
}
if (value === null) {
return;
}
if (value.length > 64 * 1024) {
Debugger.console_debug('CACHE', 'Warning - not caching large cache entry', key);
return;
}
let key_encoded = Rsx_Cache._encodekey(key);
Core_Cache._caches.global[key_encoded] = JSON.stringify(value);
Core_Cache._caches.instance[key_encoded] = JSON.stringify(value);
// Debugger.console_debug("CACHE", "Set", key, value);
Core_Cache._caches_set++;
// Reset cache after 5000 items set
if (Core_Cache._caches_set > 5000) {
// Get an accurate count
Core_Cache._caches_set = count(Core_Cache._caches.global);
if (Core_Cache._caches_set > 5000) {
Core_Cache._caches = {
global: {},
instance: {},
};
Core_Cache._caches_set = 0;
}
}
}
// Returns null on failure
// Returns a cached value from session cache (shared between browser tabs)
static get_session(key) {
if (Main.debug('no_api_cache')) {
return null;
}
if (!Rsx_Cache._supportsStorage()) {
return null;
}
let key_encoded = Rsx_Cache._encodekey(key);
let rs = sessionStorage.getItem(key_encoded);
if (!empty(rs)) {
return JSON.parse(rs);
} else {
return null;
}
}
// Sets a value in session cache (shared between browser tabs)
static set_session(key, value, _tryagain = true) {
if (Main.debug('no_api_cache')) {
return;
}
if (value.length > 64 * 1024) {
Debugger.console_debug('CACHE', 'Warning - not caching large cache entry', key);
return;
}
if (!Rsx_Cache._supportsStorage()) {
return null;
}
let key_encoded = Rsx_Cache._encodekey(key);
try {
sessionStorage.removeItem(key_encoded);
sessionStorage.setItem(key_encoded, JSON.stringify(value));
} catch (e) {
if (Rsx_Cache._isOutOfSpace(e) && sessionStorage.length) {
sessionStorage.clear();
if (_tryagain) {
Core_Cache.set_session(key, value, false);
}
}
}
}
static _reset() {
Core_Cache._caches.instance = {};
}
/**
* For given key of any type including an object, return a string representing
* the key that the cached value should be stored as in sessionstorage
*/
static _encodekey(key) {
const prefix = 'cache_';
// Session reimplement
// var prefix = "cache_" + Spa.session().user_id() + "_";
if (is_string(key) && key.length < 150 && key.indexOf(' ') == -1) {
return prefix + Manifest.build_key() + '_' + key;
} else {
return prefix + hash([Manifest.build_key(), key]);
}
}
// Determines if sessionStorage is supported in the browser;
// result is cached for better performance instead of being run each time.
// Feature detection is based on how Modernizr does it;
// it's not straightforward due to FF4 issues.
// It's not run at parse-time as it takes 200ms in Android.
// Code from https://github.com/pamelafox/lscache/blob/master/lscache.js, Apache License Pamelafox
static _supportsStorage() {
let key = '__cachetest__';
let value = key;
if (Rsx_Cache.__supportsStorage !== undefined) {
return Rsx_Cache.__supportsStorage;
}
// some browsers will throw an error if you try to access local storage (e.g. brave browser)
// hence check is inside a try/catch
try {
if (!sessionStorage) {
return false;
}
} catch (ex) {
return false;
}
try {
sessionStorage.setItem(key, value);
sessionStorage.removeItem(key);
Rsx_Cache.__supportsStorage = true;
} catch (e) {
// If we hit the limit, and we don't have an empty sessionStorage then it means we have support
if (Rsx_Cache._isOutOfSpace(e) && sessionStorage.length) {
Rsx_Cache.__supportsStorage = true; // just maxed it out and even the set test failed.
} else {
Rsx_Cache.__supportsStorage = false;
}
}
return Rsx_Cache.__supportsStorage;
}
// Check to set if the error is us dealing with being out of space
static _isOutOfSpace(e) {
return e && (e.name === 'QUOTA_EXCEEDED_ERR' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED' || e.name === 'QuotaExceededError');
}
}

46
app/RSpade/Core/Js/Rsx_Init.js Executable file
View File

@@ -0,0 +1,46 @@
/**
* Rsx_Init - Core framework initialization and environment validation
*/
class Rsx_Init {
/**
* Called via Rsx._rsx_core_boot
* Initializes the core environment and runs basic sanity checks
*/
static _on_framework_core_init() {
if (!Rsx.is_prod()) {
Rsx_Init.__environment_checks();
}
}
/**
* Development environment checks to ensure proper configuration
*/
static __environment_checks() {
// Find all script tags in the DOM
const scripts = document.getElementsByTagName('script');
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i];
// Skip inline scripts (no src attribute)
if (!script.src) {
continue;
}
// Check if script has defer attribute
if (!script.defer) {
const src = script.src || '(inline script)';
const reason = `All script tags used in an RSpade project must have defer attribute. Found script without defer: ${src}`;
// Stop framework boot with reason
Rsx._rsx_core_boot_stop(reason);
// Also log to console for visibility
console.error(`[RSX BOOT STOPPED] ${reason}`);
// Stop checking after first violation
return;
}
}
}
}

View File

@@ -0,0 +1,208 @@
// @JS-THIS-01-EXCEPTION
/**
* jQuery helper extensions for the RSX framework
* These extensions add utility methods to jQuery's prototype
* Note: 'this' references in jQuery extensions refer to jQuery objects by design
*/
class Rsx_Jq_Helpers {
/**
* Initialize jQuery extensions when the framework core is defined
* This method is called during framework initialization
*/
static _on_framework_core_define() {
// Returns true if jquery selector matched an element
$.fn.exists = function () {
return this.length > 0;
};
// Returns true if jquery element is visible
$.fn.is_visible = function () {
return this.is(':visible');
};
// Scrolls to the target element, only scrolls up. Todo: Create a version
// of this that also scrolls only down, or both
$.fn.scroll_up_to = function (speed = 0) {
if (!this.exists()) {
// console.warn("Could not find target element to scroll to");
return;
}
if (!this.is_in_dom()) {
// console.warn("Target element for scroll is not on dom");
return;
}
let e_top = Math.round(this.offset().top);
let s_top = $('body').scrollTop();
if (e_top < 0) {
let target = s_top + e_top;
$('html, body').animate(
{
scrollTop: target,
},
speed
);
}
};
// $().is(":focus") - check if element has focus
$.expr[':'].focus = function (elem) {
return elem === document.activeElement && (elem.type || elem.href);
};
// Save native click behavior before override
$.fn._click_native = $.fn.click;
// Override .click() to call preventDefault by default
// This prevents accidental page navigation/form submission - the correct behavior 95% of the time
$.fn.click = function (handler) {
// If no handler provided, trigger click event (jQuery .click() with no args)
if (typeof handler === 'undefined') {
return this._click_native();
}
// Attach click handler with automatic preventDefault
return this.on('click', function (e) {
// Save original preventDefault
const original_preventDefault = e.preventDefault.bind(e);
// Override preventDefault to show warning when called explicitly
e.preventDefault = function() {
console.warn('event.preventDefault() is called automatically by RSpade .click() handlers and can be removed.');
return original_preventDefault();
};
// Call preventDefault before handler
original_preventDefault();
return handler.call(this, e);
});
};
// Escape hatch: click handler without preventDefault for the 5% case
$.fn.click_allow_default = function (handler) {
if (typeof handler === 'undefined') {
return this._click_native();
}
return this._click_native(handler);
};
// Returns true if the jquery element exists in and is attached to the DOM
$.fn.is_in_dom = function () {
let $element = this;
let _ancestor = function (HTMLobj) {
while (HTMLobj.parentElement) {
HTMLobj = HTMLobj.parentElement;
}
return HTMLobj;
};
return _ancestor($element[0]) === document.documentElement;
};
// Returns true if the element is visible in the viewport
$.fn.is_in_viewport = function () {
let scrolltop = $(window).scrollTop() > 0 ? $(window).scrollTop() : $('body').scrollTop();
let $element = this;
const top_of_element = $element.offset().top;
const bottom_of_element = $element.offset().top + $element.outerHeight();
const bottom_of_screen = scrolltop + $(window).innerHeight();
const top_of_screen = scrolltop;
if (bottom_of_screen > top_of_element && top_of_screen < bottom_of_element) {
return true;
} else {
return false;
}
};
// Gets the tagname of a jquery element
$.fn.tagname = function () {
return this.prop('tagName').toLowerCase();
};
// Returns true if a href is not same domain
$.fn.is_external = function () {
const host = window.location.host;
const link = $('<a>', {
href: this.attr('href'),
})[0].hostname;
return link !== host;
};
// HTML5 form validation wrappers
$.fn.checkValidity = function () {
if (this.length === 0) return false;
return this[0].checkValidity();
};
$.fn.reportValidity = function () {
if (this.length === 0) return false;
return this[0].reportValidity();
};
$.fn.requestSubmit = function () {
if (this.length === 0) return this;
this[0].requestSubmit();
return this;
};
// Override $.ajax to prevent direct AJAX calls to local server
// Developers must use the Ajax endpoint pattern: await Controller.method(params)
const native_ajax = $.ajax;
$.ajax = function (url, options) {
// Handle both $.ajax(url, options) and $.ajax(options) signatures
let settings;
if (typeof url === 'string') {
settings = options || {};
settings.url = url;
} else {
settings = url || {};
}
// Check if this is a local request (relative URL or same domain)
const request_url = settings.url || '';
const is_relative = !request_url.match(/^https?:\/\//);
const is_same_domain = request_url.startsWith(window.location.origin);
const is_local_request = is_relative || is_same_domain;
// Allow framework Ajax.call() to function
if (settings.__local_integration === true) {
return native_ajax.call(this, settings);
}
// Block local AJAX requests that don't use the Ajax endpoint pattern
if (is_local_request) {
// Try to parse controller and action from URL
let controller_name = null;
let action_name = null;
const url_match = request_url.match(/\/_rsx_api\/([^\/]+)\/([^\/\?]+)/);
if (url_match) {
controller_name = url_match[1];
action_name = url_match[2];
}
let error_message = 'AJAX requests to localhost via $.ajax() are prohibited.\n\n';
if (controller_name && action_name) {
error_message += `Instead of:\n`;
error_message += ` $.ajax({url: '${request_url}', ...})\n\n`;
error_message += `Use:\n`;
error_message += ` await ${controller_name}.${action_name}(parameters)\n\n`;
} else {
error_message += `Use the Ajax endpoint pattern:\n`;
error_message += ` await Controller_Name.action_name(parameters)\n\n`;
}
error_message += `The controller method must have the #[Ajax_Endpoint] attribute.`;
shouldnt_happen(error_message);
}
// Allow external requests (different domain)
return native_ajax.call(this, settings);
};
}
}

View File

@@ -0,0 +1,185 @@
// @FILE-SUBCLASS-01-EXCEPTION
/**
* Base class for JavaScript ORM models
*
* Provides core functionality for fetching records from backend PHP models.
* All model stubs generated by the manifest extend this base class.
*
* Example usage:
* // Fetch single record
* const user = await User_Model.fetch(123);
*
* // Fetch multiple records
* const users = await User_Model.fetch([1, 2, 3]);
*
* // Create instance with data
* const user = new User_Model({id: 1, name: 'John'});
*
* @Instantiatable
*/
class Rsx_Js_Model {
/**
* Constructor - Initialize model instance with data
*
* @param {Object} data - Key-value pairs to populate the model
*/
constructor(data = {}) {
// __MODEL SYSTEM: Enables automatic ORM instantiation when fetching from PHP models.
// PHP models add "__MODEL": "ClassName" to JSON, JavaScript uses it to create proper instances.
// This provides typed model objects instead of plain JSON, with methods and type checking.
// This constructor filters out the __MODEL marker that was used to identify which class
// to instantiate, keeping only the actual data properties on the instance.
const { __MODEL, ...modelData } = data;
Object.assign(this, modelData);
}
/**
* Fetch record(s) from the backend model
*
* This method mirrors the PHP Model::fetch() functionality.
* The backend model must have a fetch() method with the
* #[Ajax_Endpoint_Model_Fetch] annotation to be callable.
*
* @param {number|Array} id - Single ID or array of IDs to fetch
* @returns {Promise} - Single model instance, array of instances, or false
*/
static async fetch(id) {
const CurrentClass = this;
// Get the model class name from the current class
const modelName = CurrentClass.name;
const response = await $.ajax({
url: `/_fetch/${modelName}`,
method: 'POST',
data: { id: id },
dataType: 'json',
});
// Handle response based on type
if (response === false) {
return false;
}
// Use _instantiate_models_recursive to handle ORM instantiation
// This will automatically detect __MODEL properties and create appropriate instances
return Rsx_Js_Model._instantiate_models_recursive(response);
}
/**
* Get the model class name
* Used internally for API calls
*
* @returns {string} The class name
*/
static getModelName() {
const CurrentClass = this;
return CurrentClass.name;
}
/**
* Refresh this instance with latest data from server
*
* @returns {Promise} Updated instance or false if not found
*/
async refresh() {
const that = this;
if (!that.id) {
shouldnt_happen('Cannot refresh model without id property');
}
const fresh = await that.constructor.fetch(that.id);
if (fresh === false) {
return false;
}
// Update this instance with fresh data
Object.assign(that, fresh);
return that;
}
/**
* Convert model instance to plain object
* Useful for serialization or sending to APIs
*
* @returns {Object} Plain object representation
*/
toObject() {
const that = this;
const obj = {};
for (const key in that) {
if (that.hasOwnProperty(key) && typeof that[key] !== 'function') {
obj[key] = that[key];
}
}
return obj;
}
/**
* Convert model instance to JSON string
*
* @returns {string} JSON representation
*/
toJSON() {
const that = this;
return JSON.stringify(that.toObject());
}
/**
* Recursively instantiate ORM models in response data
*
* Looks for objects with __MODEL property and instantiates the appropriate
* JavaScript model class if it exists in the global scope.
*
* @param {*} data - The data to process (can be any type)
* @returns {*} The data with ORM objects instantiated
*/
static _instantiate_models_recursive(data) {
// __MODEL SYSTEM: Enables automatic ORM instantiation when fetching from PHP models.
// PHP models add "__MODEL": "ClassName" to JSON, JavaScript uses it to create proper instances.
// This provides typed model objects instead of plain JSON, with methods and type checking.
// This recursive processor scans all API response data looking for __MODEL markers.
// When found, it attempts to instantiate the appropriate JavaScript model class,
// converting {__MODEL: "User_Model", id: 1, name: "John"} into new User_Model({...}).
// Works recursively through arrays and nested objects to handle complex data structures.
// Handle null/undefined
if (data === null || data === undefined) {
return data;
}
// Handle arrays - recursively process each element
if (Array.isArray(data)) {
return data.map((item) => Rsx_Js_Model._instantiate_models_recursive(item));
}
// Handle objects
if (typeof data === 'object') {
// Check if this object has a __MODEL property
if (data.__MODEL && typeof data.__MODEL === 'string') {
// Try to find the model class in the global scope
const ModelClass = window[data.__MODEL];
// If the model class exists and extends Rsx_Js_Model, instantiate it
// Dynamic model resolution requires checking class existence - @JS-DEFENSIVE-01-EXCEPTION
if (ModelClass && ModelClass.prototype instanceof Rsx_Js_Model) {
return new ModelClass(data);
}
}
// Recursively process all object properties
const result = {};
for (const key in data) {
if (data.hasOwnProperty(key)) {
result[key] = Rsx_Js_Model._instantiate_models_recursive(data[key]);
}
}
return result;
}
// Return primitive values as-is
return data;
}
}

View File

@@ -0,0 +1,164 @@
// @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
/**
* Rsx_Route_Proxy - Type-safe route URL generator
*
* SPECIAL ARCHITECTURAL NOTE:
* This class intentionally diverges from RSX's standard static-only pattern.
* It uses instance properties and methods to enable the syntactic sugar:
* let url = Rsx.Route('Controller_Name', 'action_name').url();
*
* Each call to Rsx.Route() creates a new instance of Rsx_Route_Proxy that
* encapsulates the specific route pattern and required parameters. This
* allows for clean method chaining and parameter validation.
*
* WHY INSTANCE-BASED:
* - Encapsulates route-specific data (pattern, params) per instance
* - Enables fluent interface for URL generation
* - Provides type safety by validating params at runtime
* - Allows method chaining: .url(), .absolute_url(), .navigate()
*
* This divergence from static classes is appropriate here because each
* route proxy represents a specific route with its own state, not a
* collection of utility methods.
*
* @Instantiatable
*/
class Rsx_Route_Proxy {
/**
* Constructor
*
* @param {string} class_name The controller class name
* @param {string} method_name The action/method name
* @param {string} pattern The route pattern
*/
constructor(class_name, method_name, pattern) {
this._class = class_name;
this._method = method_name;
this._pattern = pattern;
this._required_params = [];
// Extract required parameters from the pattern
this._extract_required_params();
}
/**
* Extract required parameters from the route pattern
*/
_extract_required_params() {
const that = this;
// Match all :param patterns in the route
const matches = that._pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
if (matches) {
// Remove the : prefix from each match
that._required_params = matches.map((match) => match.substring(1));
}
}
/**
* Check if this route matches the current controller and action
*
* @returns {boolean} True if this is the current route
*/
is_current() {
const that = this;
// Get current controller and action from window.rsxapp if available
return that._class === window.rsxapp.current_controller && that._method === window.rsxapp.current_action;
}
/**
* Generate a relative URL for the route
*
* @param {Object} params Parameters to fill into the route
* @returns {string} The generated URL
* @throws {Error} If required parameters are missing
*/
url(params = {}) {
const that = this;
// Check if the method name starts with '#' - indicates unimplemented route
if (that._method.startsWith('#')) {
return '#';
}
// Check for required parameters
const missing = [];
for (const required of that._required_params) {
if (!(required in params)) {
missing.push(required);
}
}
if (missing.length > 0) {
throw new Error(
`Required parameters [${missing.join(', ')}] are missing for route ` + `${that._pattern} on ${that._class}::${that._method}`
);
}
// Build the URL by replacing parameters
let url = that._pattern;
const used_params = {};
for (const param_name of that._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;
}
/**
* Generate an absolute URL for the route
*
* @param {Object} params Parameters to fill into the route
* @returns {string} The generated absolute URL
* @throws {Error} If required parameters are missing
*/
absolute_url(params = {}) {
const that = this;
// Check if the method name starts with '#' - indicates unimplemented route
if (that._method.startsWith('#')) {
return '#';
}
// Get the relative URL first
const relative_url = that.url(params);
// Get the current protocol and host
const protocol = window.location.protocol;
const host = window.location.host; // includes port if non-standard
return protocol + '//' + host + relative_url;
}
/**
* Navigate to the route by setting window.location.href
*
* @param {Object} params Parameters to fill into the route
* @throws {Error} If required parameters are missing
*/
navigate(params = {}) {
const that = this;
const url = that.url(params);
window.location.href = url;
}
}

View File

@@ -0,0 +1,54 @@
/**
* View_Transitions - Smooth page-to-page transitions using View Transitions API
*
* Enables cross-document view transitions so the browser doesn't paint the new page
* until it's ready, creating smooth animations between pages.
*
* Falls back gracefully if View Transitions API is not available.
*/
class Rsx_View_Transitions {
/**
* Called during framework core init phase
* Checks for View Transitions API support and enables if available
*/
static _on_framework_core_init() {
// Check if View Transitions API is supported
if (!document.startViewTransition) {
console_debug('VIEW_TRANSITIONS', 'View Transitions API not supported, skipping');
return;
}
// Enable cross-document view transitions via CSS
Rsx_View_Transitions._inject_transition_css();
}
/**
* Inject CSS to enable cross-document view transitions
*
* The @view-transition { navigation: auto; } rule tells the browser to:
* 1. Capture a snapshot of the current page before navigation
* 2. Fetch the new page
* 3. Wait until the new page is fully loaded and painted (document.ready)
* 4. Animate smoothly between the two states
*
* This prevents the white flash during navigation and creates app-like transitions.
*/
static _inject_transition_css() {
const style = document.createElement('style');
style.textContent = `
@view-transition {
navigation: auto;
}
/* Disable animation - instant transition */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0s;
}
`;
document.head.appendChild(style);
}
}

161
app/RSpade/Core/Js/async.js Executable file
View File

@@ -0,0 +1,161 @@
/*
* Async utility functions for the RSpade framework.
* These functions handle asynchronous operations, delays, debouncing, and mutexes.
*/
// ============================================================================
// ASYNC UTILITIES
// ============================================================================
/**
* Pauses execution for specified milliseconds
* @param {number} [milliseconds=0] - Delay in milliseconds (0 uses requestAnimationFrame)
* @returns {Promise<void>} Promise that resolves after delay
* @example await sleep(1000); // Wait 1 second
*/
function sleep(milliseconds = 0) {
return new Promise((resolve) => {
if (milliseconds == 0 && requestAnimationFrame) {
requestAnimationFrame(resolve);
} else {
setTimeout(resolve, milliseconds);
}
});
}
/**
* Creates a debounced function with exclusivity and promise fan-in
*
* This function, when invoked, immediately runs the callback exclusively.
* For subsequent invocations, it applies a delay before running the callback exclusively again.
* The delay starts after the current asynchronous operation resolves.
*
* If 'delay' is set to 0, the function will only prevent enqueueing multiple executions of the
* same method more than once, but will still run them immediately in an exclusive sequential manner.
*
* The most recent invocation of the function will be the parameters that get passed to the function
* when it invokes.
*
* The function returns a promise that resolves when the next exclusive execution completes.
*
* @param {function} callback The callback function to be invoked
* @param {number} delay The delay in milliseconds before subsequent invocations
* @param {boolean} immediate if true, the first time the action is called, the callback executes immediately
* @returns {function} A function that when invoked, runs the callback immediately and exclusively,
*
* @decorator
*/
function debounce(callback, delay, immediate = false) {
let running = false;
let queued = false;
let last_end_time = 0; // timestamp of last completed run
let timer = null;
let next_args = [];
let resolve_queue = [];
let reject_queue = [];
const run_function = async () => {
const these_resolves = resolve_queue;
const these_rejects = reject_queue;
const args = next_args;
resolve_queue = [];
reject_queue = [];
next_args = [];
queued = false;
running = true;
try {
const result = await callback(...args);
for (const resolve of these_resolves) resolve(result);
} catch (err) {
for (const reject of these_rejects) reject(err);
} finally {
running = false;
last_end_time = Date.now();
if (queued) {
clearTimeout(timer);
timer = setTimeout(run_function, Math.max(delay, 0));
} else {
timer = null;
}
}
};
return function (...args) {
next_args = args;
return new Promise((resolve, reject) => {
resolve_queue.push(resolve);
reject_queue.push(reject);
// Nothing running and nothing scheduled
if (!running && !timer) {
const first_call = last_end_time === 0;
if (immediate && first_call) {
run_function();
return;
}
const since = first_call ? Infinity : Date.now() - last_end_time;
if (since >= delay) {
run_function();
} else {
const wait = Math.max(delay - since, 0);
clearTimeout(timer);
timer = setTimeout(run_function, wait);
}
return;
}
// If we're already running or a timer exists, just mark queued.
// The finally{} of run_function handles scheduling after full delay.
queued = true;
});
};
}
// ============================================================================
// READ-WRITE LOCK FUNCTIONS - Delegated to ReadWriteLock class
// ============================================================================
/**
* Acquire an exclusive write lock by name.
* Only one writer runs at a time; blocks readers until finished.
* @param {string} name
* @param {() => any|Promise<any>} cb
* @returns {Promise<any>}
*/
function rwlock(name, cb) {
return ReadWriteLock.acquire(name, cb);
}
/**
* Acquire a shared read lock by name.
* Multiple readers run in parallel, but readers are blocked by queued/active writers.
* @param {string} name
* @param {() => any|Promise<any>} cb
* @returns {Promise<any>}
*/
function rwlock_read(name, cb) {
return ReadWriteLock.acquire_read(name, cb);
}
/**
* Forcefully clear all locks and queues for a given name.
* @param {string} name
*/
function rwlock_force_unlock(name) {
ReadWriteLock.force_unlock(name);
}
/**
* Inspect lock state for debugging.
* @param {string} name
* @returns {{readers:number, writer_active:boolean, reader_q:number, writer_q:number}}
*/
function rwlock_pending(name) {
return ReadWriteLock.pending(name);
}

198
app/RSpade/Core/Js/browser.js Executable file
View File

@@ -0,0 +1,198 @@
/*
* Browser and DOM utility functions for the RSpade framework.
* These functions handle browser detection, viewport utilities, and DOM manipulation.
*/
// ============================================================================
// BROWSER DETECTION
// ============================================================================
/**
* Detects if user is on a mobile device or using mobile viewport
* @returns {boolean} True if mobile device or viewport < 992px
* @todo Improve user agent detection for all mobile devices
*/
function is_mobile() {
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
return true;
} else if ($(window).width() < 992) {
// 992px = bootstrap 4 col-md-
return true;
} else {
return false;
}
}
/**
* Detects if user is on desktop (not mobile)
* @returns {boolean} True if not mobile device/viewport
*/
function is_desktop() {
return !is_mobile();
}
/**
* Detects the user's operating system
* @returns {string} OS name: 'Mac OS', 'iPhone', 'iPad', 'Windows', 'Android-Phone', 'Android-Tablet', 'Linux', or 'Unknown'
*/
function get_os() {
let user_agent = window.navigator.userAgent,
platform = window.navigator.platform,
macos_platforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'],
windows_platforms = ['Win32', 'Win64', 'Windows', 'WinCE'],
ios_platforms = ['iPhone', 'iPad', 'iPod'],
os = null;
let is_mobile_device = is_mobile();
if (macos_platforms.indexOf(platform) !== -1) {
os = 'Mac OS';
} else if (ios_platforms.indexOf(platform) !== -1 && is_mobile_device) {
os = 'iPhone';
} else if (ios_platforms.indexOf(platform) !== -1 && !is_mobile_device) {
os = 'iPad';
} else if (windows_platforms.indexOf(platform) !== -1) {
os = 'Windows';
} else if (/Android/.test(user_agent) && is_mobile_device) {
os = 'Android-Phone';
} else if (/Android/.test(user_agent) && !is_mobile_device) {
os = 'Android-Tablet';
} else if (!os && /Linux/.test(platform)) {
os = 'Linux';
} else {
os = 'Unknown';
}
return os;
}
/**
* Detects if the user agent is a web crawler/bot
* @returns {boolean} True if user agent appears to be a bot/crawler
*/
function is_crawler() {
let user_agent = navigator.userAgent;
let bot_pattern = /bot|spider|crawl|slurp|archiver|ping|search|dig|tracker|monitor|snoopy|yahoo|baidu|msn|ask|teoma|axios/i;
return bot_pattern.test(user_agent);
}
// ============================================================================
// DOM SCROLLING UTILITIES
// ============================================================================
/**
* Scrolls parent container to make target element visible if needed
* @param {string|HTMLElement|jQuery} target - Target element to scroll into view
*/
function scroll_into_view_if_needed(target) {
const $target = $(target);
// Find the closest parent with overflow-y: auto
const $parent = $target.parent();
// Calculate the absolute top position of the target
const target_top = $target.position().top + $parent.scrollTop();
const target_height = $target.outerHeight();
const parent_height = $parent.height();
const scroll_position = $parent.scrollTop();
// Check if the target is out of view
if (target_top < scroll_position || target_top + target_height > scroll_position + parent_height) {
Debugger.console_debug('UI', 'Scrolling!', target_top);
// Calculate the new scroll position to center the target
let new_scroll_position = target_top + target_height / 2 - parent_height / 2;
// Limit the scroll position between 0 and the maximum scrollable height
new_scroll_position = Math.max(0, Math.min(new_scroll_position, $parent[0].scrollHeight - parent_height));
// Scroll the parent to the new scroll position
$parent.scrollTop(new_scroll_position);
}
}
/**
* Scrolls page to make target element visible if needed (with animation)
* @param {string|HTMLElement|jQuery} target - Target element to scroll into view
*/
function scroll_page_into_view_if_needed(target) {
const $target = $(target);
// Calculate the absolute top position of the target relative to the document
const target_top = $target.offset().top;
const target_height = $target.outerHeight();
const window_height = $(window).height();
const window_scroll_position = $(window).scrollTop();
// Check if the target is out of view
if (target_top < window_scroll_position || target_top + target_height > window_scroll_position + window_height) {
Debugger.console_debug('UI', 'Scrolling!', target_top);
// Calculate the new scroll position to center the target
const new_scroll_position = target_top + target_height / 2 - window_height / 2;
// Animate the scroll to the new position
$('html, body').animate(
{
scrollTop: new_scroll_position,
},
1000
); // duration of the scroll animation in milliseconds
}
}
// ============================================================================
// DOM UTILITIES
// ============================================================================
/**
* Waits for all images on the page to load
* @param {Function} callback - Function to call when all images are loaded
*/
function wait_for_images(callback) {
const $images = $('img'); // Get all img tags
const total_images = $images.length;
let images_loaded = 0;
if (total_images === 0) {
callback(); // if there are no images, immediately call the callback
}
$images.each(function () {
const img = new Image();
img.onload = function () {
images_loaded++;
if (images_loaded === total_images) {
callback(); // call the callback when all images are loaded
}
};
img.onerror = function () {
images_loaded++;
if (images_loaded === total_images) {
callback(); // also call the callback if an image fails to load
}
};
img.src = this.src; // this triggers the loading
});
}
/**
* Creates a jQuery element containing a non-breaking space
* @returns {jQuery} jQuery span element with &nbsp;
*/
function $nbsp() {
return $('<span>&nbsp;</span>');
}
/**
* Escapes special characters in a jQuery selector
* @param {string} id - Element ID to escape
* @returns {string} jQuery selector string with escaped special characters
* @warning Not safe for security-critical operations
*/
function escape_jq_selector(id) {
return '#' + id.replace(/(:|\.|\[|\]|,|=|@)/g, '\\$1');
}

28
app/RSpade/Core/Js/datetime.js Executable file
View File

@@ -0,0 +1,28 @@
/*
* Date and time utility functions for the RSpade framework.
* These functions handle date/time conversions and Unix timestamps.
*/
// ============================================================================
// DATE/TIME UTILITIES
// ============================================================================
/**
* Gets the current Unix timestamp (seconds since epoch)
* @returns {number} Current Unix timestamp in seconds
* @todo Calculate based on server time at page render
* @todo Move to a date library
*/
function unix_time() {
return Math.round(new Date().getTime() / 1000);
}
/**
* Converts a date string to Unix timestamp
* @param {string} str_date - Date string (Y-m-d H:i:s format)
* @returns {number} Unix timestamp in seconds
*/
function ymdhis_to_unix(str_date) {
const date = new Date(str_date);
return date.getTime() / 1000;
}

25
app/RSpade/Core/Js/decorator.js Executable file
View File

@@ -0,0 +1,25 @@
/**
* Decorator function that marks a function as a decorator implementation.
*
* When a function has @decorator in its JSDoc comment, it whitelists that function
* to be used as a decorator on other methods throughout the codebase.
*
* The function itself performs no operation - it simply returns its input unchanged.
* Its purpose is purely as a marker for the manifest validation system.
*
* Usage:
* // /**
* // * My custom decorator implementation
* // * @decorator
* // *\/
* function my_custom_decorator(target, key, descriptor) {
* // Decorator implementation
* }
*
* This allows my_custom_decorator to be used as @my_custom_decorator on static methods.
*
* TODO: This is probably no longer necessary? maybe?
*/
function decorator(value) {
return value;
}

81
app/RSpade/Core/Js/error.js Executable file
View File

@@ -0,0 +1,81 @@
/*
* Error handling utility functions for the RSpade framework.
* These functions handle error creation and debugging utilities.
*/
// ============================================================================
// ERROR HANDLING
// ============================================================================
/**
* Creates an error object from a string
* @param {string|Object} str - Error message or existing error object
* @param {number} [error_code] - Optional error status code
* @returns {Object} Error object with error and status properties
*/
function error(str, error_code) {
if (typeof str.error != undef) {
return str;
} else {
if (typeof error_code == undef) {
return { error: str, status: null };
} else {
return { error: str, status: error_code };
}
}
}
/**
* Sanity check failure handler for JavaScript
*
* This function should be called when a sanity check fails - i.e., when the code
* encounters a condition that "shouldn't happen" if everything is working correctly.
*
* Unlike PHP, we can't stop JavaScript execution, but we can:
* 1. Throw an error that will be caught by error handlers
* 2. Log a clear error to the console
* 3. Provide stack trace for debugging
*
* Use this instead of silently returning or continuing when encountering unexpected conditions.
*
* @param {string} message Optional specific message about what shouldn't have happened
* @throws {Error} Always throws with location and context information
*/
function shouldnt_happen(message = null) {
const error = new Error();
const stack = error.stack || '';
const stackLines = stack.split('\n');
// Get the caller location (skip the Error line and this function)
let callerInfo = 'unknown location';
if (stackLines.length > 2) {
const callerLine = stackLines[2] || stackLines[1] || '';
// Extract file and line number from stack trace
const match = callerLine.match(/at\s+.*?\s+\((.*?):(\d+):(\d+)\)/) || callerLine.match(/at\s+(.*?):(\d+):(\d+)/);
if (match) {
callerInfo = `${match[1]}:${match[2]}`;
}
}
let errorMessage = `Fatal: shouldnt_happen() was called at ${callerInfo}\n`;
errorMessage += 'This indicates a sanity check failed - the code is not behaving as expected.\n';
if (message) {
errorMessage += `Details: ${message}\n`;
}
errorMessage += 'Please thoroughly review the related code to determine why this error occurred.';
// Log to console with full visibility
console.error('='.repeat(80));
console.error('SANITY CHECK FAILURE');
console.error('='.repeat(80));
console.error(errorMessage);
console.error('Stack trace:', stack);
console.error('='.repeat(80));
// Throw error to stop execution flow
const fatalError = new Error(errorMessage);
fatalError.name = 'SanityCheckFailure';
throw fatalError;
}

438
app/RSpade/Core/Js/functions.js Executable file
View File

@@ -0,0 +1,438 @@
/*
* Core utility functions for the RSpade framework.
* These functions handle type checking, type conversion, string manipulation,
* and object/array utilities. They mirror functionality from PHP functions.
*
* Other utility functions are organized in:
* - async.js: Async utilities (sleep, debounce, mutex)
* - browser.js: Browser/DOM utilities (is_mobile, scroll functions)
* - datetime.js: Date/time utilities
* - hash.js: Hashing and comparison
* - error.js: Error handling
*/
// Todo: test that prod build identifies and removes uncalled functions from the final bundle.
// ============================================================================
// CONSTANTS AND HELPERS
// ============================================================================
// Define commonly used constants
const undef = 'undefined';
/**
* Iterates over arrays or objects with promise support
*
* Works with both synchronous and asynchronous callbacks. If the callback
* returns promises, they are executed in parallel and this function returns
* a promise that resolves when all parallel tasks complete.
*
* @param {Array|Object} obj - Collection to iterate
* @param {Function} callback - Function to call for each item (value, key) - can be async
* @returns {Promise|undefined} Promise if any callbacks return promises, undefined otherwise
*
* @example
* // Synchronous usage
* foreach([1,2,3], (val) => console.log(val));
*
* @example
* // Asynchronous usage - waits for all to complete
* await foreach([1,2,3], async (val) => {
* await fetch('/api/process/' + val);
* });
*/
function foreach(obj, callback) {
const results = [];
if (Array.isArray(obj)) {
obj.forEach((value, index) => {
results.push(callback(value, index));
});
} else if (obj && typeof obj === 'object') {
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
results.push(callback(obj[key], key));
}
}
}
// Filter for promises
const promises = results.filter((result) => result && typeof result.then === 'function');
// If there are any promises, return Promise.all to wait for all to complete
if (promises.length > 0) {
return Promise.all(promises);
}
// No promises returned, so we're done
return undefined;
}
// ============================================================================
// TYPE CHECKING FUNCTIONS
// ============================================================================
/**
* Checks if a value is numeric
* @param {*} n - Value to check
* @returns {boolean} True if the value is a finite number
*/
function is_numeric(n) {
return !isNaN(parseFloat(n)) && isFinite(n);
}
/**
* Checks if a value is a string
* @param {*} s - Value to check
* @returns {boolean} True if the value is a string
*/
function is_string(s) {
return typeof s == 'string';
}
/**
* Checks if a value is an integer
* @param {*} n - Value to check
* @returns {boolean} True if the value is an integer
*/
function is_integer(n) {
return Number.isInteger(n);
}
/**
* Checks if a value is a promise-like object
* @param {*} obj - Value to check
* @returns {boolean} True if the value has a then method
*/
function is_promise(obj) {
return typeof obj == 'object' && typeof obj.then == 'function';
}
/**
* Checks if a value is an array
* @param {*} obj - Value to check
* @returns {boolean} True if the value is an array
*/
function is_array(obj) {
return Array.isArray(obj);
}
/**
* Checks if a value is an object (excludes null)
* @param {*} obj - Value to check
* @returns {boolean} True if the value is an object and not null
*/
function is_object(obj) {
return typeof obj === 'object' && obj !== null;
}
/**
* Checks if a value is a function
* @param {*} function_to_check - Value to check
* @returns {boolean} True if the value is a function
*/
function is_function(function_to_check) {
return function_to_check && {}.toString.call(function_to_check) === '[object Function]';
}
/**
* Checks if a string is a valid email address
* Uses a practical RFC 5322 compliant regex that matches 99.99% of real-world email addresses
* @param {string} email - Email address to validate
* @returns {boolean} True if the string is a valid email address
*/
function is_email(email) {
if (!is_string(email)) {
return false;
}
const regex = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
return regex.test(email);
}
/**
* Checks if a value is defined (not undefined)
* @param {*} value - Value to check
* @returns {boolean} True if value is not undefined
*/
function isset(value) {
return typeof value != undef;
}
/**
* Checks if a value is empty (null, undefined, 0, "", empty array/object)
* @param {*} object - Value to check
* @returns {boolean} True if the value is considered empty
*/
function empty(object) {
if (typeof object == undef) {
return true;
}
if (object === null) {
return true;
}
if (typeof object == 'string' && object == '') {
return true;
}
if (typeof object == 'number') {
return object == 0;
}
if (Array.isArray(object)) {
return !object.length;
}
if (typeof object == 'function') {
return false;
}
for (let key in object) {
if (object.hasOwnProperty(key)) {
return false;
}
}
return true;
}
// ============================================================================
// TYPE CONVERSION FUNCTIONS
// ============================================================================
/**
* Converts a value to a floating point number
* Returns 0 for null, undefined, NaN, or non-numeric values
* @param {*} val - Value to convert
* @returns {number} Floating point number
*/
function float(val) {
// Handle null, undefined, empty string
if (val === null || val === undefined || val === '') {
return 0.0;
}
// Try to parse the value
const parsed = parseFloat(val);
// Check for NaN and return 0 if parsing failed
return isNaN(parsed) ? 0.0 : parsed;
}
/**
* Converts a value to an integer
* Returns 0 for null, undefined, NaN, or non-numeric values
* @param {*} val - Value to convert
* @returns {number} Integer value
*/
function int(val) {
// Handle null, undefined, empty string
if (val === null || val === undefined || val === '') {
return 0;
}
// Try to parse the value
const parsed = parseInt(val, 10);
// Check for NaN and return 0 if parsing failed
return isNaN(parsed) ? 0 : parsed;
}
/**
* Converts a value to a string
* Returns empty string for null or undefined
* @param {*} val - Value to convert
* @returns {string} String representation
*/
function str(val) {
// Handle null and undefined specially
if (val === null || val === undefined) {
return '';
}
// Convert to string
return String(val);
}
// ============================================================================
// STRING MANIPULATION FUNCTIONS
// ============================================================================
/**
* Escapes HTML special characters (uses Lodash escape)
* @param {string} str - String to escape
* @returns {string} HTML-escaped string
*/
function html(str) {
return _.escape(str);
}
/**
* Converts newlines to HTML line breaks
* @param {string} str - String to convert
* @returns {string} String with newlines replaced by <br />
*/
function nl2br(str) {
if (typeof str === undef || str === null) {
return '';
}
return (str + '').replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1<br />$2');
}
/**
* Escapes HTML and converts newlines to <br />
* @param {string} str - String to process
* @returns {string} HTML-escaped string with line breaks
*/
function htmlbr(str) {
return nl2br(html(str));
}
/**
* URL-encodes a string
* @param {string} str - String to encode
* @returns {string} URL-encoded string
*/
function urlencode(str) {
return encodeURIComponent(str);
}
/**
* URL-decodes a string
* @param {string} str - String to decode
* @returns {string} URL-decoded string
*/
function urldecode(str) {
return decodeURIComponent(str);
}
/**
* JSON-encodes a value
* @param {*} value - Value to encode
* @returns {string} JSON string
*/
function json_encode(value) {
return JSON.stringify(value);
}
/**
* JSON-decodes a string
* @param {string} str - JSON string to decode
* @returns {*} Decoded value
*/
function json_decode(str) {
return JSON.parse(str);
}
/**
* Console debug output with channel filtering
* Alias for Debugger.console_debug
* @param {string} channel - Debug channel name
* @param {...*} values - Values to log
*/
function console_debug(channel, ...values) {
Debugger.console_debug(channel, ...values);
}
/**
* Replaces all occurrences of a substring in a string
* @param {string} string - String to search in
* @param {string} search - Substring to find
* @param {string} replace - Replacement substring
* @returns {string} String with all occurrences replaced
*/
function replace_all(string, search, replace) {
if (!is_string(string)) {
string = string + '';
}
return string.split(search).join(replace);
}
/**
* Capitalizes the first letter of each word
* @param {string} input - String to capitalize
* @returns {string} String with first letter of each word capitalized
*/
function ucwords(input) {
return input
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
// ============================================================================
// OBJECT AND ARRAY UTILITIES
// ============================================================================
/**
* Counts the number of properties in an object or elements in an array
* @param {Object|Array} o - Object or array to count
* @returns {number} Number of own properties/elements
*/
function count(o) {
let c = 0;
for (const k in o) {
if (o.hasOwnProperty(k)) {
++c;
}
}
return c;
}
/**
* Creates a shallow clone of an object, array, or function
* @param {*} obj - Value to clone
* @returns {*} Cloned value
*/
function clone(obj) {
if (typeof Function.prototype.__clone == undef) {
Function.prototype.__clone = function () {
//https://stackoverflow.com/questions/1833588/javascript-clone-a-function
const that = this;
let temp = function cloned() {
return that.apply(this, arguments);
};
for (let key in this) {
if (this.hasOwnProperty(key)) {
temp[key] = this[key];
}
}
return temp;
};
}
if (typeof obj == 'function') {
return obj.__clone();
} else if (obj.constructor && obj.constructor == Array) {
return obj.slice(0);
} else {
// https://stackoverflow.com/questions/728360/how-do-i-correctly-clone-a-javascript-object/30042948#30042948
return Object.assign({}, obj);
}
}
/**
* Returns the first non-null/undefined value from arguments
* @param {...*} arguments - Values to check
* @returns {*} First non-null/undefined value, or null if none found
*/
function coalesce() {
let args = Array.from(arguments);
let return_val = null;
args.forEach(function (arg) {
if (return_val === null && typeof arg != undef && arg !== null) {
return_val = arg;
}
});
return return_val;
}
/**
* Converts CSV string to array, trimming each element
* @param {string} str_csv - CSV string to convert
* @returns {Array<string>} Array of trimmed values
* @todo Handle quoted/escaped characters
*/
function csv_to_array_trim(str_csv) {
const parts = str_csv.split(',');
const ret = [];
foreach(parts, (part) => {
ret.push(part.trim());
});
return ret;
}

116
app/RSpade/Core/Js/hash.js Executable file
View File

@@ -0,0 +1,116 @@
/*
* Hashing and comparison utility functions for the RSpade framework.
* These functions handle object hashing and deep comparison.
*/
// ============================================================================
// HASHING AND COMPARISON
// ============================================================================
/**
* Generates a unique hash for any value (handles objects, arrays, circular references)
* @param {*} the_var - Value to hash
* @param {boolean} [calc_sha1=true] - If true, returns SHA1 hash; if false, returns JSON
* @param {Array<string>} [ignored_keys=null] - Keys to ignore when hashing objects
* @returns {string} SHA1 hash or JSON string of the value
*/
function hash(the_var, calc_sha1 = true, ignored_keys = null) {
if (typeof the_var == undef) {
the_var = '__undefined__';
}
if (ignored_keys === null) {
ignored_keys = ['$'];
}
// Converts value to json, discarding circular references
let json_stringify_nocirc = function (value) {
const cache = [];
return JSON.stringify(value, function (key, v) {
if (typeof v === 'object' && typeof the_var._cache_key == 'function') {
return the_var._hash_key();
} else if (typeof v === 'object' && v !== null) {
if (cache.indexOf(v) !== -1) {
// Duplicate reference found, discard key
return;
}
cache.push(v);
}
return v;
});
};
// Turn every property and all its children into a single depth array of values that we can then
// sort and hash as a whole
let flat_var = {};
let _flatten = function (the_var, prefix, depth = 0) {
// If a class object is provided, circular references can make the call stack recursive.
// For the purposes of how the hash function is called, this should be sufficient.
if (depth > 10) {
return;
}
// Does not account for dates i think...
if (is_object(the_var) && typeof the_var._cache_key == 'function') {
// Use _cache_key to hash components
flat_var[prefix] = the_var._hash_key();
} else if (is_object(the_var) && typeof Abstract !== 'undefined' && the_var instanceof Abstract) {
// Stringify all class objects
flat_var[prefix] = json_stringify_nocirc(the_var);
} else if (is_object(the_var)) {
// Iterate other objects
flat_var[prefix] = {};
for (let k in the_var) {
if (the_var.hasOwnProperty(k) && ignored_keys.indexOf(k) == -1) {
_flatten(the_var[k], prefix + '..' + k, depth + 1);
}
}
} else if (is_array(the_var)) {
// Iterate arrays
flat_var[prefix] = [];
let i = 0;
foreach(the_var, (v) => {
_flatten(v, prefix + '..' + i, depth + 1);
i++;
});
} else if (is_function(the_var)) {
// nothing
} else if (!is_numeric(the_var)) {
flat_var[prefix] = String(the_var);
} else {
flat_var[prefix] = the_var;
}
};
_flatten(the_var, '_');
let sorter = [];
foreach(flat_var, function (v, k) {
sorter.push([k, v]);
});
sorter.sort(function (a, b) {
return a[0] > b[0];
});
let json = JSON.stringify(sorter);
if (calc_sha1) {
let hashed = sha1.sha1(json);
return hashed;
} else {
return json;
}
}
/**
* Deep comparison of two values (ignores property order and functions)
* @param {*} a - First value to compare
* @param {*} b - Second value to compare
* @returns {boolean} True if values are deeply equal
*/
function deep_equal(a, b) {
return hash(a, false) == hash(b, false);
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\RSpade\Core\Kernels;
use InvalidArgumentException;
use App\RSpade\Core\Manifest\ManifestModule_Abstract;
/**
* Kernel that manages manifest processing modules
*/
#[Instantiatable]
class ManifestKernel
{
/**
* Registered modules
*/
protected array $modules = [];
/**
* Sorted modules cache
*/
protected ?array $sorted_modules = null;
/**
* Register a module
*/
public function register(string $module_class): void
{
if (!is_subclass_of($module_class, ManifestModule_Abstract::class)) {
throw new InvalidArgumentException(
"Module {$module_class} must extend " . ManifestModule_Abstract::class
);
}
$this->modules[] = $module_class;
$this->sorted_modules = null; // Clear cache
}
/**
* Process a file through all applicable modules
*/
public function process(string $file_path, array $metadata): array
{
$extension = $this->get_file_extension($file_path);
foreach ($this->get_sorted_modules() as $module) {
if (in_array($extension, $module->handles())) {
$metadata = $module->process($file_path, $metadata);
}
}
return $metadata;
}
/**
* Get modules sorted by priority
*/
public function get_sorted_modules(): array
{
if ($this->sorted_modules === null) {
$instances = array_map(
fn ($class) => app($class),
$this->modules
);
usort($instances, fn ($a, $b) => $a->priority() <=> $b->priority());
$this->sorted_modules = $instances;
}
return $this->sorted_modules;
}
/**
* Get file extension, handling double extensions like blade.php
*/
protected function get_file_extension(string $file_path): string
{
$filename = basename($file_path);
// Check for double extensions
if (preg_match('/\.blade\.php$/', $filename)) {
return 'blade.php';
}
return pathinfo($filename, PATHINFO_EXTENSION);
}
/**
* Clear all registered modules
*/
public function clear(): void
{
$this->modules = [];
$this->sorted_modules = null;
}
/**
* Get count of registered modules
*/
public function count(): int
{
return count($this->modules);
}
}

View File

@@ -0,0 +1,627 @@
<?php
namespace App\RSpade\Core\Locks;
use \Redis;
use Exception;
use RuntimeException;
// Ensure helpers are loaded since we run early in bootstrap
$helpers_path = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'helpers.php';
if (file_exists($helpers_path)) {
require_once $helpers_path;
}
/**
* Advisory locking system using Redis
*
* Implements readers-writer lock pattern:
* - Multiple readers can hold locks simultaneously
* - Writers get exclusive access (no readers or other writers)
* - Writers wait for existing readers to drain
* - New readers wait if a writer is pending
*
* IMPORTANT: This class operates independently of the manifest system
* and can be used during manifest building itself.
*/
class RsxLocks
{
// Lock domains
const SERVER_LOCK = 'server';
const DATABASE_LOCK = 'database'; // For future extension if needed
// Lock types
const READ_LOCK = 'READ';
const WRITE_LOCK = 'WRITE';
// System-wide lock names (these are the ONLY locks used in the system)
const LOCK_APPLICATION = 'APPLICATION'; // Global application lock for all requests
const LOCK_MANIFEST_BUILD = 'MANIFEST_BUILD'; // Manifest rebuild operations
const LOCK_BUNDLE_BUILD = 'BUNDLE_BUILD'; // Bundle compilation operations
const LOCK_MIGRATION = 'MIGRATION'; // Database migration operations
// Site-specific lock prefix (appended with site_id)
const LOCK_SITE_PREFIX = 'SITE_'; // e.g., SITE_1, SITE_2, etc.
// Redis configuration
private static ?\Redis $redis = null;
private static int $lock_db = 1; // Database 1 for locks (no eviction)
private static int $default_timeout = 30; // Default lock timeout in seconds
private static float $poll_interval = 0.1; // 100ms polling interval
// Track locks held by this process for cleanup
private static array $held_locks = [];
/**
* Initialize Redis connection for locking
* Separate from cache to avoid manifest dependency
* Returns null in IDE context if Redis extension not available
*/
private static function _ensure_redis(): ?\Redis
{
// Skip Redis in IDE context if extension not available
if (\is_ide() && !class_exists('\Redis')) {
return null;
}
if (self::$redis === null) {
if (!class_exists('\Redis')) {
return null;
}
self::$redis = new \Redis();
// Connect to Redis (will be configured via environment)
$host = env('REDIS_HOST', '127.0.0.1');
$port = env('REDIS_PORT', 6379);
$socket = env('REDIS_SOCKET', null);
if ($socket && file_exists($socket)) {
$connected = self::$redis->connect($socket);
}
else {
$connected = self::$redis->connect($host, $port, 2.0);
}
if (!$connected) {
shouldnt_happen("Failed to connect to Redis for locking");
}
// Select the lock database (no eviction)
self::$redis->select(self::$lock_db);
// Register shutdown handler to release locks on exit
register_shutdown_function([self::class, '_cleanup_locks']);
}
return self::$redis;
}
/**
* Get an advisory lock
*
* @param string $domain Lock domain (SERVER_LOCK or DATABASE_LOCK)
* @param string $name Lock identifier (e.g., MANIFEST_BUILD_LOCK_ID)
* @param string $type Lock type (READ_LOCK or WRITE_LOCK)
* @param int $timeout Maximum seconds to wait for lock acquisition
* @return string Lock token for release
*/
public static function get_lock(
string $domain,
string $name,
string $type = self::READ_LOCK,
int $timeout = 30
): string {
if (!in_array($domain, [self::SERVER_LOCK, self::DATABASE_LOCK])) {
shouldnt_happen("Invalid lock domain: {$domain}");
}
if (!in_array($type, [self::READ_LOCK, self::WRITE_LOCK])) {
shouldnt_happen("Invalid lock type: {$type}");
}
$redis = self::_ensure_redis();
// Skip lock in IDE context without Redis
if ($redis === null) {
return 'ide-mock-token-' . uniqid();
}
$lock_key = "lock:{$domain}:{$name}";
$lock_token = uniqid(gethostname() . ':' . getmypid() . ':', true);
$start_time = microtime(true);
// Use Lua script for atomic operations
if ($type === self::READ_LOCK) {
$acquired = self::_acquire_read_lock($redis, $lock_key, $lock_token, $timeout, $start_time);
} else {
$acquired = self::_acquire_write_lock($redis, $lock_key, $lock_token, $timeout, $start_time);
}
if (!$acquired) {
throw new RuntimeException(
"Failed to acquire {$type} lock for {$domain}:{$name} after {$timeout} seconds"
);
}
// Track lock for cleanup
self::$held_locks[$lock_token] = [
'domain' => $domain,
'name' => $name,
'type' => $type,
'key' => $lock_key,
'acquired_at' => time()
];
return $lock_token;
}
/**
* Release an advisory lock
*
* @param string $lock_token Token returned from get_lock
*/
public static function release_lock(string $lock_token): void
{
// Skip mock tokens from IDE context
if (str_starts_with($lock_token, 'ide-mock-token-')) {
return;
}
if (!isset(self::$held_locks[$lock_token])) {
// Lock already released or not owned by this process
return;
}
$lock_info = self::$held_locks[$lock_token];
$redis = self::_ensure_redis();
// Skip if Redis not available in IDE
if ($redis === null) {
unset(self::$held_locks[$lock_token]);
return;
}
$lock_key = $lock_info['key'];
if ($lock_info['type'] === self::READ_LOCK) {
self::_release_read_lock($redis, $lock_key, $lock_token);
} else {
self::_release_write_lock($redis, $lock_key, $lock_token);
}
unset(self::$held_locks[$lock_token]);
}
/**
* Upgrade a read lock to a write lock atomically
*
* This is used for optimistic concurrency control where we start with a read lock
* and only upgrade to write if needed. The read lock is NOT released during upgrade.
*
* @param string $lock_token Existing read lock token
* @param int $timeout Maximum seconds to wait for upgrade
* @return string New write lock token
* @throws RuntimeException if upgrade fails or token is not a read lock
*/
public static function upgrade_lock(string $lock_token, int $timeout = 30): string
{
// Skip mock tokens from IDE context
if (str_starts_with($lock_token, 'ide-mock-token-')) {
return 'ide-mock-token-' . uniqid();
}
if (!isset(self::$held_locks[$lock_token])) {
throw new RuntimeException("Cannot upgrade lock - token not found or already released");
}
$lock_info = self::$held_locks[$lock_token];
if ($lock_info['type'] !== self::READ_LOCK) {
throw new RuntimeException("Can only upgrade READ locks to WRITE locks");
}
$redis = self::_ensure_redis();
// Skip if Redis not available in IDE
if ($redis === null) {
return 'ide-mock-token-' . uniqid();
}
$lock_key = $lock_info['key'];
$new_token = uniqid(gethostname() . ':' . getmypid() . ':write:', true);
$start_time = microtime(true);
// Attempt atomic upgrade
$upgraded = self::_upgrade_read_to_write_lock(
$redis,
$lock_key,
$lock_token,
$new_token,
$timeout,
$start_time
);
if (!$upgraded) {
throw new RuntimeException(
"Failed to upgrade read lock to write lock for {$lock_info['domain']}:{$lock_info['name']} after {$timeout} seconds"
);
}
// Remove old read lock from tracking
unset(self::$held_locks[$lock_token]);
// Add new write lock to tracking
self::$held_locks[$new_token] = [
'domain' => $lock_info['domain'],
'name' => $lock_info['name'],
'type' => self::WRITE_LOCK,
'key' => $lock_key,
'acquired_at' => time(),
'upgraded_from' => $lock_token
];
return $new_token;
}
/**
* Acquire a read lock
*/
private static function _acquire_read_lock(
Redis $redis,
string $lock_key,
string $lock_token,
int $timeout,
float $start_time
): bool {
$writer_queue_key = "{$lock_key}:writer_queue";
$readers_key = "{$lock_key}:readers";
$writer_active_key = "{$lock_key}:writer_active";
while ((microtime(true) - $start_time) < $timeout) {
// Lua script for atomic read lock acquisition
$lua = <<<'LUA'
local lock_key = KEYS[1]
local writer_queue_key = KEYS[2]
local readers_key = KEYS[3]
local writer_active_key = KEYS[4]
local lock_token = ARGV[1]
local ttl = ARGV[2]
-- Check if a writer is active
local writer_active = redis.call('EXISTS', writer_active_key)
if writer_active == 1 then
return 0 -- Writer is active, cannot acquire read lock
end
-- Check if writers are waiting
local writers_waiting = redis.call('LLEN', writer_queue_key)
if writers_waiting > 0 then
return 0 -- Writers are waiting, new readers must wait
end
-- Add this reader
redis.call('HSET', readers_key, lock_token, '1')
redis.call('EXPIRE', readers_key, ttl)
return 1 -- Successfully acquired read lock
LUA;
$result = $redis->eval(
$lua,
[$lock_key, $writer_queue_key, $readers_key, $writer_active_key, $lock_token, self::$default_timeout],
4
);
if ($result === 1) {
return true;
}
// Wait before retrying
usleep(self::$poll_interval * 1000000);
}
return false;
}
/**
* Acquire a write lock
*/
private static function _acquire_write_lock(
Redis $redis,
string $lock_key,
string $lock_token,
int $timeout,
float $start_time
): bool {
$writer_queue_key = "{$lock_key}:writer_queue";
$readers_key = "{$lock_key}:readers";
$writer_active_key = "{$lock_key}:writer_active";
// First, add ourselves to the writer queue
$redis->rPush($writer_queue_key, $lock_token);
$redis->expire($writer_queue_key, self::$default_timeout);
try {
while ((microtime(true) - $start_time) < $timeout) {
// Lua script for atomic write lock acquisition
$lua = <<<'LUA'
local lock_key = KEYS[1]
local writer_queue_key = KEYS[2]
local readers_key = KEYS[3]
local writer_active_key = KEYS[4]
local lock_token = ARGV[1]
local ttl = ARGV[2]
-- Check if we're next in the writer queue
local next_writer = redis.call('LINDEX', writer_queue_key, 0)
if next_writer ~= lock_token then
return 0 -- Not our turn yet
end
-- Check if any readers are active
local reader_count = redis.call('HLEN', readers_key)
if reader_count > 0 then
return 0 -- Readers still active, must wait
end
-- Check if another writer is active
local writer_active = redis.call('EXISTS', writer_active_key)
if writer_active == 1 then
return 0 -- Another writer is active
end
-- Acquire the write lock
redis.call('SET', writer_active_key, lock_token, 'EX', ttl)
redis.call('LPOP', writer_queue_key) -- Remove ourselves from queue
return 1 -- Successfully acquired write lock
LUA;
$result = $redis->eval(
$lua,
[$lock_key, $writer_queue_key, $readers_key, $writer_active_key, $lock_token, self::$default_timeout],
4
);
if ($result === 1) {
return true;
}
// Wait before retrying
usleep(self::$poll_interval * 1000000);
}
} catch (Exception $e) {
// Remove from queue on error
$redis->lRem($writer_queue_key, $lock_token, 0);
throw $e;
}
// Timeout - remove from queue
$redis->lRem($writer_queue_key, $lock_token, 0);
return false;
}
/**
* Upgrade a read lock to a write lock atomically
*
* IMPORTANT: To prevent deadlock, readers that are upgrading to writers
* are NOT counted as blocking readers. This allows multiple readers to
* upgrade without deadlocking each other.
*/
private static function _upgrade_read_to_write_lock(
Redis $redis,
string $lock_key,
string $old_token,
string $new_token,
int $timeout,
float $start_time
): bool {
$writer_queue_key = "{$lock_key}:writer_queue";
$readers_key = "{$lock_key}:readers";
$writer_active_key = "{$lock_key}:writer_active";
$upgrading_readers_key = "{$lock_key}:upgrading_readers";
// Mark ourselves as an upgrading reader (not blocking other upgrades)
$redis->sAdd($upgrading_readers_key, $old_token);
$redis->expire($upgrading_readers_key, self::$default_timeout);
// Add ourselves to the writer queue
$redis->rPush($writer_queue_key, $new_token); // Normal queue order
$redis->expire($writer_queue_key, self::$default_timeout);
try {
while ((microtime(true) - $start_time) < $timeout) {
// Lua script for atomic lock upgrade (deadlock-safe)
$lua = <<<'LUA'
local lock_key = KEYS[1]
local writer_queue_key = KEYS[2]
local readers_key = KEYS[3]
local writer_active_key = KEYS[4]
local upgrading_readers_key = KEYS[5]
local old_token = ARGV[1]
local new_token = ARGV[2]
local ttl = ARGV[3]
-- Verify we still hold the read lock
local has_read_lock = redis.call('HEXISTS', readers_key, old_token)
if has_read_lock == 0 then
return -1 -- Lost our read lock somehow
end
-- Check if we're next in the writer queue
local next_writer = redis.call('LINDEX', writer_queue_key, 0)
if next_writer ~= new_token then
return 0 -- Not our turn yet
end
-- Check if any OTHER readers are active (excluding upgrading readers)
-- This prevents deadlock where multiple readers wait for each other
local all_readers = redis.call('HKEYS', readers_key)
local upgrading = redis.call('SMEMBERS', upgrading_readers_key)
local upgrading_set = {}
for i, token in ipairs(upgrading) do
upgrading_set[token] = true
end
local blocking_readers = 0
for i, reader_token in ipairs(all_readers) do
-- Don't count ourselves or other upgrading readers as blockers
if reader_token ~= old_token and not upgrading_set[reader_token] then
blocking_readers = blocking_readers + 1
end
end
if blocking_readers > 0 then
return 0 -- Non-upgrading readers still active, must wait
end
-- Check if another writer is active
local writer_active = redis.call('EXISTS', writer_active_key)
if writer_active == 1 then
return 0 -- Another writer is active
end
-- Upgrade to write lock atomically
-- 1. Remove our read lock
redis.call('HDEL', readers_key, old_token)
-- 2. Remove from upgrading readers set
redis.call('SREM', upgrading_readers_key, old_token)
-- 3. Set write lock
redis.call('SET', writer_active_key, new_token, 'EX', ttl)
-- 4. Remove from writer queue
redis.call('LPOP', writer_queue_key)
return 1 -- Successfully upgraded
LUA;
$result = $redis->eval(
$lua,
[$lock_key, $writer_queue_key, $readers_key, $writer_active_key, $upgrading_readers_key, $old_token, $new_token, self::$default_timeout],
5
);
if ($result === 1) {
return true;
} elseif ($result === -1) {
// Lost our read lock - this shouldn't happen
$redis->lRem($writer_queue_key, $new_token, 0);
$redis->sRem($upgrading_readers_key, $old_token);
throw new RuntimeException("Lost read lock during upgrade attempt");
}
// Wait before retrying
usleep(self::$poll_interval * 1000000);
}
} catch (Exception $e) {
// Remove from queues on error
$redis->lRem($writer_queue_key, $new_token, 0);
$redis->sRem($upgrading_readers_key, $old_token);
throw $e;
}
// Timeout - remove from queues
$redis->lRem($writer_queue_key, $new_token, 0);
$redis->sRem($upgrading_readers_key, $old_token);
return false;
}
/**
* Release a read lock
*/
private static function _release_read_lock(Redis $redis, string $lock_key, string $lock_token): void
{
$readers_key = "{$lock_key}:readers";
$redis->hDel($readers_key, $lock_token);
}
/**
* Release a write lock
*/
private static function _release_write_lock(Redis $redis, string $lock_key, string $lock_token): void
{
$writer_active_key = "{$lock_key}:writer_active";
// Only delete if we own it
$lua = <<<'LUA'
local writer_active_key = KEYS[1]
local lock_token = ARGV[1]
local current_writer = redis.call('GET', writer_active_key)
if current_writer == lock_token then
redis.call('DEL', writer_active_key)
return 1
end
return 0
LUA;
$redis->eval($lua, [$writer_active_key, $lock_token], 1);
}
/**
* Cleanup locks on process exit
* Called by shutdown handler
*/
public static function _cleanup_locks(): void
{
foreach (array_keys(self::$held_locks) as $lock_token) {
try {
self::release_lock($lock_token);
} catch (Exception $e) {
// Ignore errors during cleanup
}
}
}
/**
* Force clear all locks for a given name (emergency use only)
*
* @param string $domain Lock domain
* @param string $name Lock name
*/
public static function force_clear_lock(string $domain, string $name): void
{
$redis = self::_ensure_redis();
// Skip if Redis not available in IDE
if ($redis === null) {
return;
}
$lock_key = "lock:{$domain}:{$name}";
$redis->del([
$lock_key,
"{$lock_key}:writer_queue",
"{$lock_key}:readers",
"{$lock_key}:writer_active"
]);
}
/**
* Get lock statistics for monitoring
*
* @param string $domain Lock domain
* @param string $name Lock name
* @return array Lock statistics
*/
public static function get_lock_stats(string $domain, string $name): array
{
$redis = self::_ensure_redis();
// Skip if Redis not available in IDE
if ($redis === null) {
return [
'readers_active' => 0,
'writers_waiting' => 0,
'writer_active' => false,
'writer_token' => null
];
}
$lock_key = "lock:{$domain}:{$name}";
$writer_queue = $redis->lLen("{$lock_key}:writer_queue");
$reader_count = $redis->hLen("{$lock_key}:readers");
$writer_active = $redis->get("{$lock_key}:writer_active");
return [
'readers_active' => $reader_count,
'writers_waiting' => $writer_queue,
'writer_active' => $writer_active !== false,
'writer_token' => $writer_active ?: null
];
}
}

View File

@@ -0,0 +1,334 @@
# Manifest System - Developer Documentation
This documentation is for developers working on Manifest.php itself. For usage documentation, see the man pages via `php artisan rsx:man manifest_api`.
## CRITICAL: Testing Manifest.php Changes
**When modifying Manifest.php, you MUST run `php artisan rsx:manifest:build --clean` to test your changes.**
The `--clean` flag performs both `rsx:clean` and a full manifest rebuild, ensuring your modifications are properly tested. Without this, the manifest may use cached or partially-built data that doesn't reflect your changes.
## Architecture Overview
The Manifest is a compiled cache of all file metadata in the RSX application, stored at `storage/rsx-build/manifest_data.php`. It replaces Laravel's scattered discovery mechanisms with a unified system that enables path-agnostic class loading.
### Core Data Structure
The manifest cache contains:
```php
[
'generated' => '2025-09-28 05:02:15',
'hash' => '42b9d0efb5c547eec0fb2ca19bf922e0',
'data' => [
'files' => [...], // All indexed files with metadata
'js_classes' => [...], // JavaScript class map
'php_classes' => [...], // PHP class map
'autoloader_class_map' => [...], // Simple name to FQCN mapping
'models' => [...], // Database model metadata
'jqhtml' => [...] // jqhtml component registry
]
]
```
### Schema Discovery Tool
Use `php artisan rsx:manifest:schema_dump` to inspect the manifest structure:
```bash
# Pretty-printed JSON for human reading
php artisan rsx:manifest:schema_dump
# Compact JSON for LLM parsing (saves tokens)
php artisan rsx:manifest:schema_dump --no-pretty-print
```
This tool deduplicates array structures, showing unique patterns with example values. Essential for understanding the actual data structure when writing code that directly accesses manifest data.
## 6-Phase Build Process
The build process in Manifest.php follows these phases:
### Phase 1: File Discovery (__discover_files)
- Scans directories from `config('rsx.manifest.scan_directories')`
- Default: `['rsx', 'app/RSpade/Core/Js', 'app/RSpade/Modules']`
- Excludes: vendor/, node_modules/, .git/, storage/, public/
- Returns array of file paths with basic stats (mtime, size)
### Phase 2: Token Parsing (__extract_basic_metadata)
- Uses `token_get_all()` for fast PHP parsing without loading
- Extracts: namespace, class name, extends, implements
- **Normalizes all class references to simple names** (strips namespace qualifiers)
- Builds dependency graph for loading order
### Phase 3: Dependency Loading (__load_changed_php_files)
- Loads PHP files in dependency order (parents before children)
- Uses `_load_class_hierarchy()` to ensure parent classes exist
- Critical for reflection to work properly
### Phase 4: Reflection & Module Processing (__process_with_modules)
- Runs registered ManifestModule processors
- Core processors:
- `Php_ManifestModule`: Extracts attributes, methods via reflection
- `Blade_ManifestModule`: Parses Blade directives
- `Javascript_ManifestModule`: Parses JS classes/methods
- `Model_ManifestSupport`: Adds database schema to models
- Stores ALL attributes without validation (agnostic extraction)
### Phase 5: Stub Generation (__generate_js_stubs, __generate_model_stubs)
- Creates JavaScript stub classes for:
- Controllers with `Ajax_Endpoint` methods → `storage/rsx-build/js-stubs/`
- Models with `fetch()` methods → `storage/rsx-build/js-model-stubs/`
- Enables clean JavaScript API calls without manual Ajax wiring
### Phase 6: Cache Writing (__write_manifest_cache)
- Serializes to PHP array format
- Writes to `storage/rsx-build/manifest_data.php`
- Uses `var_export()` for fast `include()` loading
## Key Implementation Details
### Class Name Normalization Philosophy
**CRITICAL**: RSX enforces unique simple class names across the entire codebase. This architectural constraint allows the manifest to normalize ALL class references to simple names, eliminating an entire class of bugs related to namespace format variations.
**The Problem**: PHP allows multiple ways to reference classes:
- `\Rsx\Lib\DataGrid` (leading backslash)
- `Rsx\Lib\DataGrid` (no leading backslash)
- `DataGrid` (simple name)
**The Solution**: `Manifest::_normalize_class_name()` strips namespace qualifiers:
```php
public static function _normalize_class_name(string $class_name): string
{
// Strip leading backslash
$class_name = ltrim($class_name, '\\');
// Extract just the class name (last part after final backslash)
$parts = explode('\\', $class_name);
return end($parts);
}
```
**Applied At**:
1. **Token parsing** - `Php_Parser::__extract_class_info()` normalizes `extends` at extraction
2. **Parent lookups** - `Manifest::_load_class_hierarchy()` normalizes before comparison
3. **All class name operations** - Any code comparing class names uses normalization
**Why This Works**: RSX's unique simple class name enforcement means we only need FQCNs at actual `include_once` time. Throughout the manifest, simple names are sufficient and eliminate format inconsistencies.
**Bug This Fixes**: Previously, `extends \Rsx\Lib\DataGrid` (with leading `\` from token parser) failed to match stored FQCN `Rsx\Lib\DataGrid`, causing parent class resolution failures.
### Static State Management
The Manifest uses static properties for singleton behavior:
```php
private static $_manifest = null; // Cached manifest data
private static $_is_initialized = false; // Initialization flag
private static $_manifest_loaded_at = null; // Load timestamp
```
### Public API Methods
#### Initialization & Loading
- `init()` - Ensures manifest is loaded (auto-rebuilds in dev mode)
- `get_full_manifest()` - Returns complete manifest structure with metadata
- `clear()` - Clears the in-memory cache
#### File Lookups
- `get_all()` - Returns all files array
- `get_file($path)` - Get specific file metadata (throws if not found)
- `get_files_by_dir($dir)` - Get all files in directory
#### PHP Class Resolution
- `php_find_class($name)` - Find by simple class name
- `find_php_fqcn($fqcn)` - Find by fully qualified name
- `php_get_metadata_by_class($name)` - Get metadata by class name
- `php_get_metadata_by_fqcn($fqcn)` - Get metadata by FQCN
- `php_get_extending($parent)` - Find all concrete classes extending parent (filters out abstract)
- `php_is_subclass_of($sub, $super)` - Check inheritance
#### JavaScript Class Resolution
- `js_find_class($name)` - Find JavaScript class file
- `js_get_extending($parent)` - Find extending JS classes
- `js_is_subclass_of($sub, $super)` - Check JS inheritance
#### View Resolution
- `find_view($id)` - Find by dot notation (e.g., 'frontend.index')
- `find_view_by_rsx_id($id)` - Find by @rsx_id value
#### Attribute & Route Discovery
- `get_with_attribute($attr)` - Find all files with specific attribute
- `get_routes()` - Extract all route definitions from attributes
#### Utility Methods
- `get_build_key()` - Get manifest hash for cache validation
- `get_stats()` - Get statistics (file counts, build time)
- `get_autoloader_class_map()` - Get class-to-file mappings
- `is_built()` - Check if manifest exists
- `_normalize_class_name($class_name)` - Strip namespace qualifiers to get simple name
### Attribute Extraction Philosophy
The manifest practices **agnostic attribute extraction**:
1. **No Validation** - Stores all attributes without checking validity
2. **No Instantiation** - Never creates attribute objects
3. **No Class Loading** - Attributes don't need backing classes
4. **Raw Storage** - Stores exactly what reflection provides
Example from _extract_php_metadata:
```php
foreach ($method->getAttributes() as $attribute) {
$attributes[] = [
'name' => $attribute->getName(),
'arguments' => $attribute->getArguments()
];
}
```
### Change Detection
Files are tracked by:
- `mtime` - Modification time (primary)
- `size` - File size in bytes (secondary)
- `hash` - SHA1, only computed when mtime/size change
The `__refresh_manifest()` method compares these to detect changes.
### Error Handling
All lookup methods throw `RuntimeException` when items not found:
```php
if (!isset(self::$_manifest['data']['php_classes'][$class_name])) {
throw new RuntimeException("PHP class not found in manifest: {$class_name}");
}
```
This ensures **fail-fast behavior** - no silent failures.
## Module System
### Built-in Modules
Located in `/app/RSpade/Modules/`:
- `Php_ManifestModule` - PHP reflection and attribute extraction
- `Blade_ManifestModule` - Blade directive parsing
- `Javascript_ManifestModule` - JS class/method extraction
- `Scss_ManifestModule` - SCSS metadata
- `Model_ManifestSupport` - Database schema extraction
- `Jqhtml_ManifestSupport` - jqhtml component registration
### Creating Custom Modules
Implement the `ManifestModule` interface:
```php
interface ManifestModule {
function get_name(): string;
function get_extensions(): array; // File extensions to handle
function process(string $file, array $metadata): array;
function post_process(array $manifest): void;
}
```
Register in `config/rsx.php`:
```php
'manifest' => [
'modules' => [
App\MyModule::class
]
]
```
## Performance Considerations
### Caching Strategy
- **Development**: Auto-rebuilds via `__refresh_manifest()` on file changes
- **Production**: Manual rebuild required, loads from cache file
- **Memory**: Full manifest kept in static variable after first load
### Optimization Points
- Token parsing avoids loading PHP files unnecessarily
- Dependency ordering minimizes class loading failures
- SHA1 hashing only when size/mtime indicate changes
- `var_export()` format allows fast PHP `include()`
## Common Issues & Solutions
### Issue: "Class not found" during reflection
**Cause**: Parent class not loaded before child
**Solution**: Check `_load_class_hierarchy()` is working correctly
### Issue: Parent class not found (extends mismatch)
**Cause**: Namespace format variations in `extends` declarations (e.g., `\Rsx\Lib\DataGrid` vs `Rsx\Lib\DataGrid`)
**Solution**: Use `Manifest::_normalize_class_name()` to strip namespace qualifiers before comparison
**Fixed In**: Version with class name normalization (2025-10-10)
### Issue: Attributes not appearing in manifest
**Cause**: Method not public static
**Solution**: Only public static methods are indexed
### Issue: Manifest not updating
**Cause**: In production mode or file timestamps unchanged
**Solution**: Run `php artisan rsx:manifest:build --force`
### Issue: JavaScript stubs not generating
**Cause**: Missing `Ajax_Endpoint` attribute or method not public static
**Solution**: Check method has attribute and correct visibility
## Code Quality Integration
The manifest integrates with code quality checks via metadata storage. Rules that run during manifest scan store violations in `code_quality_metadata` field for each file.
## Debugging
Enable verbose output:
```php
Manifest::$_debug_options['verbose'] = true;
```
Or via command:
```bash
php artisan rsx:manifest:build --verbose
```
## Testing Considerations
When testing manifest functionality:
1. Use `Manifest::clear()` between tests to reset state
2. Mock file system for predictable test data
3. Test both scan() and rebuild() paths
4. Verify exception throwing for not-found cases
5. Check stub generation for API methods
## Important Constants & Paths
- Cache file: `storage/rsx-build/manifest_data.php`
- JS stubs: `storage/rsx-build/js-stubs/`
- Model stubs: `storage/rsx-build/js-model-stubs/`
- Default scan dirs: `['rsx', 'app/RSpade/Core/Js', 'app/RSpade/Modules']`
## Direct Data Access
When bypassing helper methods to access manifest data directly:
```php
$manifest = Manifest::get_full_manifest();
// Access files
$file_data = $manifest['data']['files']['path/to/file.php'];
// Access class maps
$class_file = $manifest['data']['php_classes']['ClassName'];
$js_class = $manifest['data']['js_classes']['JsClass'];
// Access models with schema
$model = $manifest['data']['models']['User_Model'];
$columns = $model['columns'];
```
Use `rsx:manifest:schema_dump` to understand the exact structure before writing direct access code.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
<?php
namespace App\RSpade\Core\Manifest;
/**
* Abstract base class for modules that process files during manifest scanning
*
* Manifest modules are responsible for extracting metadata from specific file types
* during the manifest building process. Each module handles certain file extensions
* and can extract relevant information like classes, methods, attributes, etc.
*/
#[Instantiatable]
abstract class ManifestModule_Abstract
{
/**
* Get file extensions this module handles
*
* Return an array of file extensions (without dots) that this module
* should process. For example: ['php', 'blade.php']
*
* @return array File extensions this module processes
*/
abstract public function handles(): array;
/**
* Get processing priority (lower = earlier)
*
* Modules with lower priority numbers are processed first.
* This allows certain modules to take precedence over others.
* For example, Blade_ManifestModule might have higher priority than Php_ManifestModule
* to handle .blade.php files before the generic PHP handler.
*
* @return int Priority value (typically 0-100)
*/
abstract public function priority(): int;
/**
* Process a file and extract metadata
*
* This method receives a file path and existing metadata, processes
* the file to extract additional information, and returns the updated
* metadata array.
*
* @param string $file_path Full path to the file to process
* @param array $metadata Existing metadata (size, mtime, etc)
* @return array Updated metadata with extracted information
*/
abstract public function process(string $file_path, array $metadata): array;
/**
* Helper method to check if a file should be processed
*
* @param string $file_path Path to check
* @return bool True if this module should process the file
*/
protected function should_process(string $file_path): bool
{
$extensions = $this->handles();
foreach ($extensions as $ext) {
if (str_ends_with($file_path, '.' . $ext)) {
return true;
}
}
return false;
}
/**
* Helper method to get file contents safely
*
* @param string $file_path Path to file
* @return string|null File contents or null if not readable
*/
protected function get_file_contents(string $file_path): ?string
{
if (!file_exists($file_path) || !is_readable($file_path)) {
return null;
}
return file_get_contents($file_path);
}
}

Some files were not shown because too many files have changed in this diff Show More