Files
rspade_system/app/RSpade/Core/Bundle/BundleCompiler.php
root 78553d4edf Fix code quality violations for publish
Remove unused blade settings pages not linked from UI
Convert remaining frontend pages to SPA actions
Convert settings user_settings and general to SPA actions
Convert settings profile pages to SPA actions
Convert contacts and projects add/edit pages to SPA actions
Convert clients add/edit page to SPA action with loading pattern
Refactor component scoped IDs from $id to $sid
Fix jqhtml comment syntax and implement universal error component system
Update all application code to use new unified error system
Remove all backwards compatibility - unified error system complete
Phase 5: Remove old response classes
Phase 3-4: Ajax response handler sends new format, old helpers deprecated
Phase 2: Add client-side unified error foundation
Phase 1: Add server-side unified error foundation
Add unified Ajax error response system with constants

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 04:35:01 +00:00

2296 lines
84 KiB
PHP
Executable File

<?php
namespace App\RSpade\Core\Bundle;
use Exception;
use RecursiveCallbackFilterIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use App\RSpade\Core\Locks\RsxLocks;
use App\RSpade\Core\Manifest\Manifest;
/**
* BundleCompiler - Compiles RSX bundles into JS and CSS files
*
* Process flow:
* 1. Resolve all bundle includes to get flat file list
* 2. In production: check if output files exist, return immediately if yes
* 3. Split all files into vendor/app based on path containing "vendor/"
* 4. Check individual vendor/app bundle caches
* 5. Process files through bundle processors
* 6. Add JS stubs from manifest
* 7. Filter to JS/CSS only
* 8. Add NPM includes to vendor bucket
* 9. Compile vendor JS/CSS, then app JS/CSS
* 10. In production only: concatenate vendor+app into single files
*/
#[Instantiatable]
class BundleCompiler
{
/**
* Files organized by vendor/app
* ['vendor' => [files], 'app' => [files]]
*/
protected array $bundle_files = [];
/**
* Watch files organized by vendor/app
*/
protected array $watch_files = [];
/**
* CDN assets from bundles
*/
protected array $cdn_assets = ['js' => [], 'css' => []];
/**
* Public directory assets (served via AssetHandler with filemtime cache-busting)
* Format: ['js' => [['url' => '/path/to/file.js', 'full_path' => '/full/filesystem/path']], ...]
*/
protected array $public_assets = ['js' => [], 'css' => []];
/**
* Cache keys for vendor/app
*/
protected array $cache_keys = [];
/**
* NPM includes to add to vendor JS
*/
protected array $npm_includes = [];
/**
* Bundle configuration
*/
protected array $config = [];
/**
* Files already included (deduplication)
*/
protected array $included_files = [];
/**
* Include paths for route extraction
*/
protected array $include_routes = [];
/**
* Bundle name being compiled
*/
protected string $bundle_name = '';
/**
* Whether we're in production mode
*/
protected bool $is_production = false;
/**
* Track resolved includes to prevent duplicates
* This tracks bundle classes, aliases, and paths that have been processed
*/
protected array $resolved_includes = [];
/**
* Compiled jqhtml files (separated during ordering for special placement)
*/
protected array $jqhtml_compiled_files = [];
/**
* Mapping from babel-transformed files to their original source files
* ['storage/rsx-tmp/babel_xxx.js' => 'app/RSpade/Core/Js/SomeFile.js']
*/
protected array $babel_file_mapping = [];
/**
* Bundle build lock token (prevents parallel RPC server interference)
*/
protected ?string $bundle_build_lock = null;
/**
* Compile a bundle
*/
public function compile(string $bundle_class, array $options = []): array
{
$this->bundle_name = $this->_get_bundle_name($bundle_class);
$this->is_production = app()->environment('production');
console_debug('BUNDLE', "Compiling {$this->bundle_name} (production: " . ($this->is_production ? 'yes' : 'no') . ')');
// Step 1: Check production cache
if ($this->is_production) {
$existing = $this->_check_production_cache();
if ($existing) {
console_debug('BUNDLE', 'Using existing production bundle');
return $existing;
}
}
// Step 2: Mark the bundle we're compiling as already resolved
$this->resolved_includes[$bundle_class] = true;
// Step 3: Process required bundles first
$this->_process_required_bundles();
// Step 4: Resolve all bundle includes to get flat file list
$this->_resolve_bundle($bundle_class);
// Step 5: Always split into vendor/app
$this->_split_vendor_app();
// Step 6: Check individual bundle caches
$need_compile = $this->_check_bundle_caches();
console_debug('BUNDLE', 'Need compile: ' . json_encode($need_compile));
// Step 7-10: Process bundles that need compilation
if (!empty($need_compile)) {
console_debug('BUNDLE', 'Acquiring bundle build lock');
// Get a bundle build lock to prevent parallel RPC server interference
$this->bundle_build_lock = RsxLocks::get_lock(
RsxLocks::SERVER_LOCK,
RsxLocks::LOCK_BUNDLE_BUILD,
RsxLocks::WRITE_LOCK,
config('rsx.locking.timeout', 30)
);
console_debug('BUNDLE', 'Bundle build lock acquired, double-checking cache');
// Double-check cache after acquiring lock (race condition protection)
// Maybe another process just finished compiling while we were waiting
$need_compile = $this->_check_bundle_caches();
if (!empty($need_compile)) {
console_debug('BUNDLE', 'Processing bundles: ' . json_encode($need_compile));
$this->_process_bundles($need_compile);
} else {
console_debug('BUNDLE', 'Cache updated by another process, using cache');
}
} else {
console_debug('BUNDLE', 'No bundles need processing, using cache');
}
// Step 11: Compile final output files for types that need it
$outputs = $this->_compile_outputs($need_compile);
// Step 12: Add existing cached outputs for types that don't need compilation
foreach (['vendor', 'app'] as $type) {
if (!in_array($type, $need_compile)) {
// This type doesn't need compilation, get existing files
$cache_key = $this->cache_keys[$type] ?? null;
if ($cache_key) {
$short_key = substr($cache_key, 0, 8);
$bundle_dir = storage_path('rsx-build/bundles');
$js_file = "{$this->bundle_name}__{$type}.{$short_key}.js";
$css_file = "{$this->bundle_name}__{$type}.{$short_key}.css";
if (file_exists("{$bundle_dir}/{$js_file}")) {
$outputs["{$type}_js"] = $js_file;
}
if (file_exists("{$bundle_dir}/{$css_file}")) {
$outputs["{$type}_css"] = $css_file;
}
}
}
}
// Step 13: Return data for render() method (not CLI)
// Include CDN assets and proper file paths for HTML generation
$bundle_dir = storage_path('rsx-build/bundles');
$result = [];
// Add CDN assets
if (!empty($this->cdn_assets['js'])) {
$result['cdn_js'] = $this->cdn_assets['js'];
}
if (!empty($this->cdn_assets['css'])) {
$result['cdn_css'] = $this->cdn_assets['css'];
}
// Add public directory assets
if (!empty($this->public_assets['js'])) {
$result['public_js'] = $this->public_assets['js'];
}
if (!empty($this->public_assets['css'])) {
$result['public_css'] = $this->public_assets['css'];
}
// Add bundle file paths for development
if (!$this->is_production) {
if (isset($outputs['vendor_js'])) {
$result['vendor_js_bundle_path'] = $outputs['vendor_js'];
}
if (isset($outputs['app_js'])) {
$result['app_js_bundle_path'] = $outputs['app_js'];
}
if (isset($outputs['vendor_css'])) {
$result['vendor_css_bundle_path'] = $outputs['vendor_css'];
}
if (isset($outputs['app_css'])) {
$result['app_css_bundle_path'] = $outputs['app_css'];
}
} else {
// Production mode - simple concatenation of vendor + app
$js_content = '';
if (isset($outputs['vendor_js'])) {
$js_content = file_get_contents("{$bundle_dir}/{$outputs['vendor_js']}");
}
if (isset($outputs['app_js'])) {
// Simple concatenation with newline separator
if ($js_content) {
$js_content .= "\n";
}
$js_content .= file_get_contents("{$bundle_dir}/{$outputs['app_js']}");
}
$css_content = '';
if (isset($outputs['vendor_css'])) {
$css_content = file_get_contents("{$bundle_dir}/{$outputs['vendor_css']}");
}
if (isset($outputs['app_css'])) {
// Simple concatenation with newline separator
if ($css_content) {
$css_content .= "\n";
}
$css_content .= file_get_contents("{$bundle_dir}/{$outputs['app_css']}");
}
// Write combined files with content hash
if ($js_content) {
$js_hash = substr(md5($js_content), 0, 16);
$js_file = "app.{$js_hash}.js";
file_put_contents("{$bundle_dir}/{$js_file}", $js_content);
$result['js_bundle_path'] = $js_file;
}
if ($css_content) {
$css_hash = substr(md5($css_content), 0, 16);
$css_file = "app.{$css_hash}.css";
file_put_contents("{$bundle_dir}/{$css_file}", $css_content);
$result['css_bundle_path'] = $css_file;
}
}
// Add config if present
if (!empty($this->config)) {
$result['bundle_data'] = $this->config;
}
return $result;
}
// ------------------------------------------------------------------------
// ---- Private / Protected Methods:
// ------------------------------------------------------------------------
/**
* Check for JavaScript naming conflicts within files being bundled
*
* This checks for:
* - Duplicate function names
* - Duplicate const names
* - Duplicate class names
* - Conflicts between functions, consts, and classes
*
* Only checks the subset of files being bundled, not the entire manifest.
* Throws exception on first conflict found to prevent bundle compilation.
*
* @param array $types Types being compiled (vendor/app)
* @throws \App\RSpade\Core\Exceptions\YoureDoingItWrongException
*/
protected function _check_js_naming_conflicts(array $types): void
{
// Collect all JavaScript files from types being compiled
$js_files = [];
foreach ($types as $type) {
$files = $this->bundle_files[$type] ?? [];
foreach ($files as $file) {
// Only check JavaScript files
if (str_ends_with($file, '.js')) {
$js_files[] = $file;
}
}
}
if (empty($js_files)) {
return; // No JS files to check
}
console_debug('BUNDLE', 'Checking JS naming conflicts for ' . count($js_files) . ' files');
// Get manifest data
$manifest_files = Manifest::get_all();
// Track all names across the bundle
$all_names = []; // name => ['type' => 'function'|'const'|'class', 'file' => path]
// Process each JS file in the bundle
foreach ($js_files as $js_file) {
// Convert absolute path to relative for manifest lookup
$relative_path = str_replace(base_path() . '/', '', $js_file);
if (!isset($manifest_files[$relative_path])) {
continue; // File not in manifest, skip
}
$metadata = $manifest_files[$relative_path];
// Check global functions
if (!empty($metadata['global_function_names'])) {
foreach ($metadata['global_function_names'] as $func_name) {
if (isset($all_names[$func_name])) {
$this->_throw_js_naming_conflict(
$func_name,
'function',
$relative_path,
$all_names[$func_name]['type'],
$all_names[$func_name]['file']
);
}
$all_names[$func_name] = ['type' => 'function', 'file' => $relative_path];
}
}
// Check global consts
if (!empty($metadata['global_const_names'])) {
foreach ($metadata['global_const_names'] as $const_name) {
if (isset($all_names[$const_name])) {
$this->_throw_js_naming_conflict(
$const_name,
'const',
$relative_path,
$all_names[$const_name]['type'],
$all_names[$const_name]['file']
);
}
$all_names[$const_name] = ['type' => 'const', 'file' => $relative_path];
}
}
// Check class names (they share namespace with functions and consts)
if (!empty($metadata['class'])) {
$class_name = $metadata['class'];
if (isset($all_names[$class_name])) {
$this->_throw_js_naming_conflict(
$class_name,
'class',
$relative_path,
$all_names[$class_name]['type'],
$all_names[$class_name]['file']
);
}
$all_names[$class_name] = ['type' => 'class', 'file' => $relative_path];
}
}
console_debug('BUNDLE', 'JS naming check passed - no conflicts found');
}
/**
* Throw exception for JavaScript naming conflict
*/
protected function _throw_js_naming_conflict(string $name, string $type1, string $file1, string $type2, string $file2): void
{
$error_message = '==========================================';
$error_message .= "\nFATAL: JavaScript naming conflict in bundle";
$error_message .= "\n==========================================";
$error_message .= "\n\nBundle: {$this->bundle_name}";
$error_message .= "\nName: {$name}";
$error_message .= "\n\nDefined as {$type1} in: {$file1}";
$error_message .= "\nAlso defined as {$type2} in: {$file2}";
$error_message .= "\n\nPROBLEM:";
$error_message .= "\nThe name '{$name}' is used for both a {$type1} and a {$type2}.";
$error_message .= "\nJavaScript files in the same bundle share the same scope.";
if ($type1 === $type2) {
$error_message .= "\nDuplicate {$type1} definitions will conflict.";
} else {
$error_message .= "\nClasses, functions and const variables share the same namespace.";
}
$error_message .= "\n\nWHY THIS MATTERS:";
$error_message .= "\n- All files in a bundle are concatenated together";
$error_message .= "\n- Duplicate names overwrite each other or cause errors";
$error_message .= "\n- This leads to unpredictable runtime behavior";
$error_message .= "\n\nFIX OPTIONS:";
$error_message .= "\n1. Rename one of them to be unique";
$error_message .= "\n2. Move into a class as a static member";
$error_message .= "\n3. Remove from bundle if not needed";
$error_message .= "\n4. Use different bundles for conflicting files";
$error_message .= "\n==========================================";
throw new \App\RSpade\Core\Exceptions\YoureDoingItWrongException($error_message);
}
/**
* Get bundle name from class
*/
protected function _get_bundle_name(string $bundle_class): string
{
$parts = explode('\\', $bundle_class);
return end($parts);
}
/**
* Check if production bundle already exists
*/
protected function _check_production_cache(): ?array
{
$bundle_dir = storage_path('rsx-build/bundles');
$js_pattern = "{$bundle_dir}/app.*.js";
$css_pattern = "{$bundle_dir}/app.*.css";
$js_files = glob($js_pattern);
$css_files = glob($css_pattern);
if (!empty($js_files) || !empty($css_files)) {
return [
'js_bundle_path' => !empty($js_files) ? basename($js_files[0]) : null,
'css_bundle_path' => !empty($css_files) ? basename($css_files[0]) : null,
];
}
return null;
}
/**
* Process required bundles (jquery, lodash, jqhtml)
*/
protected function _process_required_bundles(): void
{
$required_bundles = config('rsx.required_bundles', []);
$bundle_aliases = config('rsx.bundle_aliases', []);
foreach ($required_bundles as $alias) {
if (isset($bundle_aliases[$alias])) {
$this->_process_include_item($bundle_aliases[$alias]);
}
}
}
/**
* Resolve bundle and all its includes
*/
protected function _resolve_bundle(string $bundle_class): void
{
// Get bundle definition
if (!method_exists($bundle_class, 'define')) {
throw new Exception("Bundle {$bundle_class} missing define() method");
}
$definition = $bundle_class::define();
// Process bundle includes
if (!empty($definition['include'])) {
foreach ($definition['include'] as $item) {
$this->_process_include_item($item);
}
}
// Process watch directories
if (!empty($definition['watch'])) {
foreach ($definition['watch'] as $dir) {
$this->_add_watch_directory($dir);
}
}
// Store CDN assets
if (!empty($definition['cdn_assets'])) {
if (!empty($definition['cdn_assets']['js'])) {
$this->cdn_assets['js'] = array_merge($this->cdn_assets['js'], $definition['cdn_assets']['js']);
}
if (!empty($definition['cdn_assets']['css'])) {
$this->cdn_assets['css'] = array_merge($this->cdn_assets['css'], $definition['cdn_assets']['css']);
}
}
// Store NPM includes
if (!empty($definition['npm'])) {
$this->npm_includes = array_merge($this->npm_includes, $definition['npm']);
}
// Store config
if (!empty($definition['config'])) {
$this->config = array_merge($this->config, $definition['config']);
}
// Process include_routes directive
if (!empty($definition['include_routes'])) {
foreach ($definition['include_routes'] as $route_path) {
// Normalize the path
$normalized_path = trim($route_path, '/');
if (!in_array($normalized_path, $this->include_routes)) {
$this->include_routes[] = $normalized_path;
}
}
}
}
/**
* Process a single include item
*/
protected function _process_include_item($item): void
{
// Create a unique key for this include item
$include_key = is_string($item) ? $item : serialize($item);
// Skip if already processed
if (isset($this->resolved_includes[$include_key])) {
return;
}
$this->resolved_includes[$include_key] = true;
// Check for /public/ prefix - static assets from public directories
if (is_string($item) && str_starts_with($item, '/public/')) {
$relative_path = substr($item, 8); // Strip '/public/' prefix
try {
// Resolve via AssetHandler (with Redis caching)
$full_path = \App\RSpade\Core\Dispatch\AssetHandler::find_public_asset($relative_path);
// Determine file type
$extension = strtolower(pathinfo($relative_path, PATHINFO_EXTENSION));
if ($extension === 'js') {
$this->public_assets['js'][] = [
'url' => '/' . $relative_path,
'full_path' => $full_path,
];
} elseif ($extension === 'css') {
$this->public_assets['css'][] = [
'url' => '/' . $relative_path,
'full_path' => $full_path,
];
} else {
throw new RuntimeException(
"Public asset must be .js or .css file: {$item}\n" .
'Only JavaScript and CSS files can be included via /public/ prefix.'
);
}
return;
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
throw new RuntimeException(
"Failed to resolve public asset: {$item}\n" .
$e->getMessage()
);
}
}
// Check bundle aliases and resolve to actual class
$bundle_aliases = config('rsx.bundle_aliases', []);
if (is_string($item) && isset($bundle_aliases[$item])) {
$item = $bundle_aliases[$item];
// Also mark the resolved class as processed
if (isset($this->resolved_includes[$item])) {
return;
}
$this->resolved_includes[$item] = true;
}
// If it's a fully qualified class, resolve as bundle
if (is_string($item) && class_exists($item)) {
$this->_resolve_bundle($item);
return;
}
// Try to find class by simple name in manifest
if (is_string($item) && strpos($item, '/') === false && strpos($item, '.') === false) {
try {
$manifest = Manifest::get_all();
foreach ($manifest as $file_info) {
if (isset($file_info['class']) && $file_info['class'] === $item) {
if (isset($file_info['fqcn']) && class_exists($file_info['fqcn'])) {
$this->_resolve_bundle($file_info['fqcn']);
return;
}
}
}
} catch (Exception $e) {
// Fall through to file/directory handling
}
}
// Check if item is an absolute path
if (str_starts_with($item, '/')) {
$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);
}
// If it starts with base_path, convert to relative
if (str_starts_with($item, $base_path)) {
// Convert absolute path to relative path (without leading slash)
$item = substr($item, strlen($base_path) + 1);
} elseif (str_starts_with($item, $project_root)) {
// Path is under project root but not under base_path (e.g., symlinked rsx/)
// Convert to relative from base_path
$relative_from_project = substr($item, strlen($project_root) + 1);
$item = $relative_from_project;
} else {
throw new RuntimeException(
"Bundle include item is an absolute path outside the project directory.\n" .
"Path: {$item}\n" .
"Project directory: {$project_root}\n" .
'Absolute paths must be within the project directory.'
);
}
}
// Validate that storage directory is not directly included
if (is_string($item) && (str_starts_with($item, 'storage/') || str_starts_with($item, '/storage/'))) {
throw new RuntimeException(
"Direct inclusion of 'storage' directory in bundle is not allowed.\n" .
"Path: {$item}\n\n" .
'Auto-generated files (JS stubs, compiled assets) are automatically included ' .
'when their source files are part of the bundle. Include the source files ' .
"(e.g., 'rsx/models' for model stubs) rather than the generated output."
);
}
// Validate path format (development mode only)
if (!app()->environment('production') && is_string($item)) {
if (str_starts_with($item, 'system/app/') || str_starts_with($item, '/system/app/')) {
throw new RuntimeException(
"'system/app/' is not a valid RSpade path. Use 'app/' instead.\n" .
"Invalid path: {$item}\n" .
'Correct path: ' . str_replace('system/app/', 'app/', $item) . "\n\n" .
'From the bundle perspective, framework code is at app/, not system/app/'
);
}
if (str_starts_with($item, 'system/rsx/') || str_starts_with($item, '/system/rsx/')) {
throw new RuntimeException(
"'system/rsx/' is not a valid RSpade path. Use 'rsx/' instead.\n" .
"Invalid path: {$item}\n" .
'Correct path: ' . str_replace('system/rsx/', 'rsx/', $item) . "\n\n" .
'From the bundle perspective, application code is at rsx/, not system/rsx/'
);
}
}
// Otherwise treat as file/directory path
$path = base_path($item);
if (is_file($path)) {
$this->_add_file($path);
return;
}
if (is_dir($path)) {
$this->_add_directory($path);
return;
}
// Try with wildcards
$files = glob($path);
if (!empty($files)) {
foreach ($files as $file) {
if (is_file($file)) {
$this->_add_file($file);
} elseif (is_dir($file)) {
$this->_add_directory($file);
}
}
return;
}
// If we get here, the item could not be resolved
throw new RuntimeException("Cannot resolve bundle include item: {$item}. Not found as alias, class, file, or directory.");
}
/**
* Add a file to the bundle
*/
protected function _add_file(string $path): void
{
$normalized = rsxrealpath($path);
if (!$normalized) {
return;
}
// Deduplicate
if (isset($this->included_files[$normalized])) {
return;
}
$this->included_files[$normalized] = true;
// Add to main files (will be split into vendor/app later)
if (!isset($this->bundle_files['all'])) {
$this->bundle_files['all'] = [];
}
$this->bundle_files['all'][] = $normalized;
}
/**
* Add all files from a directory
*/
protected function _add_directory(string $path): void
{
if (!is_dir($path)) {
return;
}
// Get excluded directories from config
$excluded_dirs = config('rsx.manifest.excluded_dirs', ['vendor', 'node_modules', 'storage', '.git', 'public', 'resource']);
// Create a recursive directory iterator with filtering
$directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS);
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excluded_dirs) {
// Check if current directory name is in excluded list
if ($current->isDir()) {
$dirname = $current->getBasename();
return !in_array($dirname, $excluded_dirs);
}
// Include all files
return true;
});
$iterator = new RecursiveIteratorIterator(
$filter,
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$this->_add_file($file->getPathname());
}
}
}
/**
* Add a watch directory
*/
protected function _add_watch_directory(string $dir): void
{
$path = base_path($dir);
if (!is_dir($path)) {
return;
}
// Get excluded directories from config
$excluded_dirs = config('rsx.manifest.excluded_dirs', ['vendor', 'node_modules', 'storage', '.git', 'public', 'resource']);
// Create a custom filter to exclude configured directories
$directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS);
$filter = new RecursiveCallbackFilterIterator($directory, function ($current, $key, $iterator) use ($excluded_dirs) {
// Skip excluded directories
if ($current->isDir() && in_array($current->getBasename(), $excluded_dirs)) {
return false;
}
return true;
});
$iterator = new RecursiveIteratorIterator(
$filter,
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$normalized = rsxrealpath($file->getPathname());
if (!isset($this->included_files[$normalized])) {
if (!isset($this->watch_files['all'])) {
$this->watch_files['all'] = [];
}
$this->watch_files['all'][] = $normalized;
}
}
}
}
/**
* Split files into vendor and app buckets
*
* IMPORTANT: Files remain as FLAT ARRAYS at this stage.
* We are extension-agnostic throughout processing.
* Files could be .php, .blade, .scss, .jqhtml, .xml, .coffee, or ANY future type.
* Only at final compilation do we filter by .js and .css extensions.
*/
protected function _split_vendor_app(): void
{
$vendor_files = [];
$app_files = [];
// Split bundle files - flat array, ALL file types mixed together
foreach ($this->bundle_files['all'] ?? [] as $file) {
if (strpos($file, '/vendor/') !== false) {
$vendor_files[] = $file;
} else {
$app_files[] = $file;
}
}
// Store as flat arrays - NOT organized by extension yet
$this->bundle_files = [
'vendor' => $vendor_files, // Flat array of ALL vendor files
'app' => $app_files, // Flat array of ALL app files
];
// Split watch files - also flat arrays
$vendor_watch = [];
$app_watch = [];
foreach ($this->watch_files['all'] ?? [] as $file) {
if (strpos($file, '/vendor/') !== false) {
$vendor_watch[] = $file;
} else {
$app_watch[] = $file;
}
}
$this->watch_files = [
'vendor' => $vendor_watch, // Flat array of ALL vendor watch files
'app' => $app_watch, // Flat array of ALL app watch files
];
console_debug('BUNDLE', 'Split files - vendor: ' . count($vendor_files) . ', app: ' . count($app_files));
}
/**
* Check bundle caches and return which need compilation
*/
protected function _check_bundle_caches(): array
{
$need_compile = [];
$this->cache_keys = []; // Store for use in compile
foreach (['vendor', 'app'] as $type) {
$cache_key = $this->_get_cache_key($type);
$this->cache_keys[$type] = $cache_key;
$cached = $this->_get_cached_bundle($cache_key, $type);
if (!$cached || !$this->_is_cache_valid($cached, $type)) {
$need_compile[] = $type;
}
}
console_debug('BUNDLE', 'Need to compile: ' . implode(', ', $need_compile));
return $need_compile;
}
/**
* Get cache key for bundle type
*/
protected function _get_cache_key(string $type): string
{
$files_for_hash = array_merge(
$this->bundle_files[$type] ?? [],
$this->watch_files[$type] ?? []
);
$hashes = [];
foreach ($files_for_hash as $file) {
if (!file_exists($file)) {
shouldnt_happen("Expected file {$file} does not exist in bundle builder");
}
$hash = _rsx_file_hash_for_build($file);
$hashes[] = $hash;
}
$final_key = md5(serialize($hashes));
return $final_key;
}
/**
* Get cached bundle if exists
*/
protected function _get_cached_bundle(string $cache_key, string $type): ?array
{
$bundle_dir = storage_path('rsx-build/bundles');
// The cache key is used as part of the filename hash
// Look for files with the specific type and hash
$short_key = substr($cache_key, 0, 8);
$js_file = "{$bundle_dir}/{$this->bundle_name}__{$type}.{$short_key}.js";
$css_file = "{$bundle_dir}/{$this->bundle_name}__{$type}.{$short_key}.css";
$files = [];
if (file_exists($js_file)) {
$files[] = $js_file;
}
if (file_exists($css_file)) {
$files[] = $css_file;
}
if (empty($files)) {
console_debug('BUNDLE', "No cached files found for {$type} with hash {$short_key}");
return null;
}
console_debug('BUNDLE', "Found cached files for {$type} with hash {$short_key}");
return ['files' => $files, 'key' => $cache_key];
}
/**
* Check if cache is still valid
*/
protected function _is_cache_valid(array $cached, string $type): bool
{
// In production, if files exist, they're valid
if ($this->is_production) {
return true;
}
// Check if all cached files still exist
foreach ($cached['files'] as $file) {
if (!file_exists($file)) {
return false;
}
}
return true;
}
/**
* Process bundles through processors
*
* IMPORTANT: Files remain as FLAT ARRAYS throughout processing.
* Processors can handle ANY file type: .php, .jqhtml, .scss, .xml, .coffee, etc.
* We do NOT organize by extension here - only in _compile_outputs.
*/
protected function _process_bundles(array $types): void
{
// Check for JavaScript naming conflicts before processing
// This only runs when bundles actually need rebuilding
$this->_check_js_naming_conflicts($types);
$processor_classes = config('rsx.bundle_processors', []);
// Sort processors by priority (lower number = higher priority)
usort($processor_classes, function ($a, $b) {
$priority_a = method_exists($a, 'get_priority') ? $a::get_priority() : 1000;
$priority_b = method_exists($b, 'get_priority') ? $b::get_priority() : 1000;
return $priority_a - $priority_b;
});
foreach ($types as $type) {
// Get flat array of ALL files
$files = $this->bundle_files[$type] ?? [];
// Run each processor with ALL files (flat array, passed by reference)
foreach ($processor_classes as $processor_class) {
if (!class_exists($processor_class)) {
continue;
}
console_debug('BUNDLE', "Running {$processor_class} on {$type} files (" . count($files) . ' files)');
// Process files - processor modifies array directly by reference
if (method_exists($processor_class, 'process_batch')) {
$processor_class::process_batch($files);
}
}
// Update the bundle files - STILL AS FLAT ARRAY
// Do NOT organize by extension here!
$this->bundle_files[$type] = array_values($files); // Re-index array
console_debug('BUNDLE', "Processed {$type} - Total files: " . count($files));
}
}
/**
* Write temporary file
*/
protected function _write_temp_file(string $content, string $extension): string
{
$hash = substr(md5($content), 0, 8);
$path = storage_path('rsx-tmp/bundle_' . $this->bundle_name . '_' . $hash . '.' . $extension);
file_put_contents($path, $content);
return $path;
}
/**
* Get JS stubs for all included files in the bundle
*
* IMPORTANT: ANY file type can have a js_stub - not just PHP files.
* Models, controllers, or even custom file types might generate JS stubs.
*/
protected function _get_js_stubs(): array
{
$stubs = [];
$manifest = Manifest::get_full_manifest();
$manifest_files = $manifest['data']['files'] ?? [];
// Get all files from all bundles (app and vendor)
$all_files = [];
foreach ($this->bundle_files as $type => $files) {
// Remember: bundle_files are flat arrays at this point
if (is_array($files)) {
$all_files = array_merge($all_files, $files);
}
}
// Check each file for JS stubs
foreach ($all_files as $file) {
$relative = str_replace(base_path() . '/', '', $file);
// ANY file can have a js_stub - check manifest for all files
if (isset($manifest_files[$relative]['js_stub'])) {
$stub_path = base_path() . '/' . $manifest_files[$relative]['js_stub'];
if (file_exists($stub_path)) {
console_debug('BUNDLE', "Adding JS stub for {$relative}: {$manifest_files[$relative]['js_stub']}");
$stubs[] = $stub_path;
} else {
console_debug('BUNDLE', "JS stub file not found: {$stub_path}");
}
}
}
console_debug('BUNDLE', 'Found ' . count($stubs) . ' JS stubs total');
return array_unique($stubs);
}
/**
* Order JavaScript files by class dependency
*
* JavaScript file ordering is critical for proper execution because:
* 1. Decorators are executed at class definition time (not invocation)
* 2. ES6 class extends clauses are evaluated at definition time
* 3. Both require their dependencies to be defined first
*
* Ordering rules:
* 1. Non-class files first (functions, constants, utilities)
* 2. Class files ordered by dependency (parent classes and decorator classes first)
* 3. Special metadata files (manifest, stubs) - added later by caller
* 4. Runner stub - added last by caller
*
* Dependency types:
* - Inheritance: class Foo extends Bar requires Bar to be defined first
* - Decorators: @Foo.bar() requires Foo class to be defined first
* - Simple decorators: @foobar() requires function foobar to be defined first
*
* We trust programmers to order non-class files correctly among themselves.
* We DO validate and enforce ordering for class dependencies.
*/
protected function _order_javascript_files_by_dependency(array $js_files): array
{
console_debug('BUNDLE_SORT', 'Starting dependency sort with ' . count($js_files) . ' files');
$manifest = Manifest::get_full_manifest();
$manifest_files = $manifest['data']['files'] ?? [];
// Arrays to hold categorized files
$class_files = [];
$jqhtml_compiled_files = [];
$non_class_files = [];
$class_info = [];
// Analyze each file for class information
foreach ($js_files as $file) {
// Check if this is a compiled jqhtml file
if (str_contains($file, 'storage/rsx-tmp/jqhtml_')) {
$jqhtml_compiled_files[] = $file;
continue;
}
// Skip ALL temp files - they won't be in manifest
// Babel and other transformations should have been applied to original files
if (str_contains($file, 'storage/rsx-tmp/')) {
$non_class_files[] = $file;
continue;
}
// Get file info from manifest
$relative = str_replace(base_path() . '/', '', $file);
$file_data = $manifest_files[$relative] ?? null;
// If file has a class defined, add to class_files
if (!empty($file_data['class'])) {
$class_files[] = $file;
// Build comprehensive class info including decorator dependencies
$class_info[$file] = [
'class' => $file_data['class'],
'extends' => $file_data['extends'] ?? null,
'decorators' => $file_data['decorators'] ?? [],
// Extract decorator dependencies from methods
'method_decorators' => $this->_extract_method_decorators($file_data),
];
} else {
// No class info - treat as non-class file
$non_class_files[] = $file;
}
}
// Order class files by dependency with circular dependency detection
$ordered_class_files = $this->_topological_sort_classes($class_files, $class_info);
// Find decorator.js and ensure it's the very first file
$decorator_file = null;
$other_non_class_files = [];
foreach ($non_class_files as $file) {
// Check if this is the decorator.js file
if (str_ends_with($file, '/app/RSpade/Core/Js/decorator.js')) {
$decorator_file = $file;
} else {
$other_non_class_files[] = $file;
}
}
// CRITICAL ORDERING:
// 1. decorator.js MUST be first (defines @decorator)
// 2. Other non-class files (utilities, decorator functions)
// 3. Class files (ordered by dependency)
// NOTE: Compiled jqhtml files are returned separately - will be inserted after JS stubs
$final_order = [];
if ($decorator_file) {
$final_order[] = $decorator_file;
}
$final_order = array_merge($final_order, $other_non_class_files, $ordered_class_files);
// Return both the ordered files and the jqhtml files separately
// Store jqhtml files in a property for use in _compile_outputs
$this->jqhtml_compiled_files = $jqhtml_compiled_files;
return $final_order;
}
/**
* Extract decorator dependencies from method metadata
*
* Scans all methods in the file metadata to find decorator usage.
* Decorators like @Foo.bar() create a dependency on class Foo.
*/
protected function _extract_method_decorators(array $file_data): array
{
$decorators = [];
// Check static methods for decorators
if (!empty($file_data['public_static_methods'])) {
foreach ($file_data['public_static_methods'] as $method) {
if (!empty($method['decorators'])) {
foreach ($method['decorators'] as $decorator) {
$decorators[] = $decorator;
}
}
}
}
// Check instance methods for decorators
if (!empty($file_data['methods'])) {
foreach ($file_data['methods'] as $method) {
if (!empty($method['decorators'])) {
foreach ($method['decorators'] as $decorator) {
$decorators[] = $decorator;
}
}
}
}
// Check properties for decorators
if (!empty($file_data['static_properties'])) {
foreach ($file_data['static_properties'] as $property) {
if (!empty($property['decorators'])) {
foreach ($property['decorators'] as $decorator) {
$decorators[] = $decorator;
}
}
}
}
return $decorators;
}
/**
* Topological sort for class dependencies with decorator support
*
* This performs a depth-first topological sort to order JavaScript classes
* such that all dependencies are defined before the classes that use them.
*
* Dependency detection:
* 1. Inheritance: class Foo extends Bar creates dependency on Bar
* 2. Class decorators: @Baz.method() creates dependency on Baz class
* 3. Method decorators: Same as class decorators
*
* Circular dependency handling:
* - Detected via temporary visit marking during DFS traversal
* - Throws exception with clear error message showing the cycle
* - Prevents infinite loops in dependency resolution
*
* Note: We don't validate if decorator classes actually exist in the bundle.
* If @Foo.bar() is used and Foo doesn't exist, that's fine - it might be
* defined globally or in a different bundle. We only order known dependencies.
*/
protected function _topological_sort_classes(array $class_files, array $class_info): array
{
$sorted = [];
$visited = [];
$temp_visited = [];
$visit_path = []; // Track the current path for circular dependency reporting
// Build a map of class names to file paths for quick lookup
$class_to_file = [];
foreach ($class_info as $file => $info) {
if (isset($info['class'])) {
$class_to_file[$info['class']] = $file;
}
}
/**
* Extract class dependencies from a decorator name
*
* Examples:
* - @Foo.bar() returns 'Foo'
* - @Route() returns null (not a class dependency)
* - @Namespace.Class.method() returns 'Namespace' (first part only)
*/
$extract_decorator_class = function ($decorator_name) {
// Check if decorator name contains a dot (class.method pattern)
if (str_contains($decorator_name, '.')) {
// Extract the class name (part before first dot)
$parts = explode('.', $decorator_name);
// Only return if it starts with uppercase (likely a class)
if (!empty($parts[0]) && ctype_upper($parts[0][0])) {
return $parts[0];
}
}
return null;
};
/**
* Depth-first search for topological sorting
*
* This recursive function visits each node (file) and its dependencies,
* building the sorted list from the bottom up (dependencies first).
*/
$visit = function ($file) use (
&$visit,
&$sorted,
&$visited,
&$temp_visited,
&$visit_path,
$class_info,
$class_to_file,
$extract_decorator_class
) {
// Check for circular dependency
if (isset($temp_visited[$file])) {
// Build the cycle path for error reporting
$cycle_start = array_search($file, $visit_path);
$cycle = array_slice($visit_path, $cycle_start);
$cycle[] = $file; // Complete the circle
// Create detailed error message
$class_name = $class_info[$file]['class'] ?? 'unknown';
$cycle_description = array_map(function ($f) use ($class_info) {
return $class_info[$f]['class'] ?? basename($f);
}, $cycle);
throw new RuntimeException(
"Circular dependency detected in JavaScript class dependencies:\n" .
' ' . implode(' -> ', $cycle_description) . "\n" .
" Files involved:\n" .
implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '', $f), $cycle)) . "\n" .
' Break the circular dependency by refactoring the class relationships.'
);
}
// Already processed this file
if (isset($visited[$file])) {
return;
}
// Mark as temporarily visited and add to path
$temp_visited[$file] = true;
$visit_path[] = $file;
// Collect all dependencies for this file
$dependencies = [];
// 1. Inheritance dependency: extends clause
if (!empty($class_info[$file]['extends'])) {
$parent_class = $class_info[$file]['extends'];
// Find the file containing the parent class
if (isset($class_to_file[$parent_class])) {
$parent_file = $class_to_file[$parent_class];
// Only add as dependency if it's a different file
if ($parent_file !== $file) {
$dependencies[] = $parent_file;
}
}
}
// 2. Class decorator dependencies: @Foo.bar() on the class itself
// Decorators are in compact format: [[name, [args]], ...]
if (!empty($class_info[$file]['decorators'])) {
foreach ($class_info[$file]['decorators'] as $decorator) {
// Compact format: [name, args]
$decorator_name = $decorator[0] ?? null;
if (!$decorator_name) {
continue;
}
$decorator_class = $extract_decorator_class($decorator_name);
if ($decorator_class && isset($class_to_file[$decorator_class])) {
$decorator_file = $class_to_file[$decorator_class];
// Only add as dependency if it's a different file
if ($decorator_file !== $file && !in_array($decorator_file, $dependencies)) {
$dependencies[] = $decorator_file;
}
}
}
}
// 3. Method decorator dependencies: decorators on methods/properties
if (!empty($class_info[$file]['method_decorators'])) {
foreach ($class_info[$file]['method_decorators'] as $decorator) {
$decorator_class = $extract_decorator_class($decorator['name']);
if ($decorator_class && isset($class_to_file[$decorator_class])) {
$decorator_file = $class_to_file[$decorator_class];
// Only add as dependency if it's a different file and not already added
if ($decorator_file !== $file && !in_array($decorator_file, $dependencies)) {
$dependencies[] = $decorator_file;
}
}
}
}
// Visit all dependencies first (depth-first)
foreach ($dependencies as $dep_file) {
$visit($dep_file);
}
// Remove from temporary visited and path
unset($temp_visited[$file]);
array_pop($visit_path);
// Mark as permanently visited and add to sorted list
$visited[$file] = true;
$sorted[] = $file;
};
// Process all class files
foreach ($class_files as $file) {
if (!isset($visited[$file])) {
$visit($file); //??
}
}
// try {
// (code above was here)
// } catch (RuntimeException $e) {
// // Re-throw with bundle context if available
// if (!empty($this->bundle_class)) {
// throw new RuntimeException(
// "Bundle compilation failed for {$this->bundle_class}:\n" . $e->getMessage(),
// 0,
// $e
// );
// }
// throw $e;
// }
return $sorted;
}
/**
* Compile final output files
*
* THIS IS THE ONLY PLACE where we organize files by extension.
* Up until now, all files have been in flat arrays regardless of type.
* Here we:
* 1. Filter to only .js and .css files
* 2. Order JS files by class dependency
* 3. Add framework code (stubs, manifest, runner)
* 4. Generate final compiled output
*/
protected function _compile_outputs(array $types_to_compile = []): array
{
$outputs = [];
$bundle_dir = storage_path('rsx-build/bundles');
if (!is_dir($bundle_dir)) {
mkdir($bundle_dir, 0755, true);
}
// Only compile types that need it
// If empty, nothing needs compiling - just return empty
if (empty($types_to_compile)) {
return $outputs;
}
// Compile requested types
foreach ($types_to_compile as $type) {
// Get the flat array of ALL files for this type
$all_files = $this->bundle_files[$type] ?? [];
// NOW we finally organize by extension for output
// This is the FIRST and ONLY time we care about extensions
$files = ['js' => [], 'css' => []];
foreach ($all_files as $file) {
$ext = pathinfo($file, PATHINFO_EXTENSION);
if ($ext === 'js') {
$files['js'][] = $file;
} elseif ($ext === 'css') {
$files['css'][] = $file;
}
// Other extensions are ignored - they should have been processed into JS/CSS
}
// Order JavaScript files by class dependency BEFORE adding framework code
if (!empty($files['js'])) {
$files['js'] = $this->_order_javascript_files_by_dependency($files['js']);
}
// Use the cache key hash for filenames
$hash = isset($this->cache_keys[$type]) ? substr($this->cache_keys[$type], 0, 8) : substr(md5($this->bundle_name . '_' . $type), 0, 8);
// Clean old files for this type only
$old_files = glob("{$bundle_dir}/{$this->bundle_name}__{$type}.*");
foreach ($old_files as $file) {
// Don't delete files with current hash
if (strpos($file, ".{$hash}.") === false) {
unlink($file);
}
}
// Add NPM includes to vendor JS
if ($type === 'vendor' && !empty($this->npm_includes)) {
$npm_bundle_path = $this->_generate_npm_includes();
if ($npm_bundle_path) {
// Add the NPM bundle file path directly
array_unshift($files['js'], $npm_bundle_path);
}
}
// Add JS stubs and framework code to app JS
if ($type === 'app') {
// Add NPM import declarations at the very beginning
if (!empty($this->npm_includes)) {
$npm_import_file = $this->_generate_npm_import_declarations();
if ($npm_import_file) {
array_unshift($files['js'], $npm_import_file);
}
}
// ALWAYS get JS stubs for ALL included files that have them
// ANY file type can have a js_stub - controllers, models, custom types, etc.
$stub_files = $this->_get_js_stubs();
foreach ($stub_files as $stub) {
$files['js'][] = $stub;
}
// Add compiled jqhtml files AFTER JS stubs
// These are JavaScript files generated from .jqhtml templates
foreach ($this->jqhtml_compiled_files as $jqhtml_file) {
$files['js'][] = $jqhtml_file;
}
// Generate manifest definitions for all JS classes
$manifest_file = $this->_create_javascript_manifest($files['js'] ?? []);
if ($manifest_file) {
$files['js'][] = $manifest_file;
}
// Generate route definitions for JavaScript
$route_file = $this->_create_javascript_routes();
if ($route_file) {
$files['js'][] = $route_file;
}
// Generate initialization runner
$runner_file = $this->_create_javascript_runner();
if ($runner_file) {
$files['js'][] = $runner_file;
}
}
// Compile JS
if (!empty($files['js'])) {
$js_content = $this->_compile_js_files($files['js']);
$js_file = "{$this->bundle_name}__{$type}.{$hash}.js";
file_put_contents("{$bundle_dir}/{$js_file}", $js_content);
$outputs["{$type}_js"] = $js_file;
}
// Compile CSS
if (!empty($files['css'])) {
$css_content = $this->_compile_css_files($files['css']);
$css_file = "{$this->bundle_name}__{$type}.{$hash}.css";
file_put_contents("{$bundle_dir}/{$css_file}", $css_content);
$outputs["{$type}_css"] = $css_file;
}
}
return $outputs;
}
/**
* Parse an import statement string into components
*
* Examples:
* "import { Component } from '@jqhtml/core'"
* "import * as React from 'react'"
* "import lodash from 'lodash'"
*
* @throws RuntimeException if statement is invalid
*/
protected function _parse_import_statement(string $statement): array
{
$statement = trim($statement);
// Must start with "import " and end with single or double quote
if (!str_starts_with($statement, 'import ')) {
throw new RuntimeException("Invalid import statement - must start with 'import ': {$statement}");
}
if (!str_ends_with($statement, "'") && !str_ends_with($statement, '"')) {
throw new RuntimeException("Invalid import statement - must end with quote: {$statement}");
}
// Extract the package name (everything between quotes)
if (preg_match("/from\\s+['\"]([^'\"]+)['\"]\\s*$/", $statement, $matches)) {
$package_name = $matches[1];
} else {
throw new RuntimeException("Invalid import statement - cannot extract package: {$statement}");
}
// Extract what's being imported (between "import" and "from")
$import_part = trim(substr($statement, 7)); // Remove "import "
$from_pos = strrpos($import_part, ' from ');
if ($from_pos === false) {
throw new RuntimeException("Invalid import statement - missing 'from': {$statement}");
}
$import_spec = trim(substr($import_part, 0, $from_pos));
// Determine the import type and extract names
if (str_starts_with($import_spec, '{') && str_ends_with($import_spec, '}')) {
// Named imports: { Component, Something }
$type = 'named';
$names = trim(substr($import_spec, 1, -1));
} elseif (str_starts_with($import_spec, '* as ')) {
// Namespace import: * as Something
$type = 'namespace';
$names = trim(substr($import_spec, 5));
} else {
// Default import: Something
$type = 'default';
$names = $import_spec;
}
return [
'statement' => $statement,
'package' => $package_name,
'type' => $type,
'imports' => $names,
];
}
/**
* Generate NPM includes using esbuild compilation
* Returns the path to the generated bundle file, not the content
*/
protected function _generate_npm_includes(): string
{
if (empty($this->npm_includes)) {
return '';
}
// Parse all import statements
$parsed_imports = [];
foreach ($this->npm_includes as $global_name => $import_statement) {
try {
$parsed = $this->_parse_import_statement($import_statement);
$parsed['global_name'] = $global_name;
$parsed_imports[] = $parsed;
} catch (RuntimeException $e) {
throw new RuntimeException(
"Invalid NPM import for '{$global_name}': " . $e->getMessage()
);
}
}
// Collect all package-lock.json files for cache key
$package_lock_contents = [];
$lock_files = [
'package-lock.json',
'internal-libs/jqhtml/package-lock.json',
'internal-libs/rsx-scss-lint/package-lock.json',
];
foreach ($lock_files as $lock_file) {
$full_path = base_path($lock_file);
if (file_exists($full_path)) {
$package_lock_contents[$lock_file] = md5_file($full_path);
}
}
// Generate cache key from:
// 1. All package-lock.json file hashes
// 2. The npm array itself (sorted for consistency)
// 3. The current working directory
$cache_components = [
'package_locks' => $package_lock_contents,
'npm_array' => $this->npm_includes,
'cwd' => getcwd(),
];
$cache_key = md5(serialize($cache_components));
// Generate bundle filename for this specific bundle
$bundle_filename = "npm_{$this->bundle_name}_{$cache_key}.js";
$bundle_path = storage_path("rsx-build/bundles/{$bundle_filename}");
// Check if bundle already exists
if (file_exists($bundle_path)) {
console_debug('BUNDLE', "Using cached NPM bundle: {$bundle_filename}");
return $bundle_path;
}
console_debug('BUNDLE', "Compiling NPM modules for {$this->bundle_name}");
// Create storage directory if needed
$bundle_dir = dirname($bundle_path);
if (!is_dir($bundle_dir)) {
mkdir($bundle_dir, 0755, true);
}
// Generate entry file content with import statements
$entry_content = "// Auto-generated NPM module exports for {$this->bundle_name}\n";
$entry_content .= "// Cache key: {$cache_key}\n\n";
// Initialize the _rsx_npm object
$entry_content .= "// Initialize RSX NPM module container\n";
$entry_content .= "window._rsx_npm = window._rsx_npm || {};\n\n";
foreach ($parsed_imports as $import) {
$global_name = $import['global_name'];
$statement = $import['statement'];
// Add the original import statement
$entry_content .= "{$statement}\n";
// Add window._rsx_npm assignment based on import type
switch ($import['type']) {
case 'named':
// For named imports, handle single vs multiple differently
$names = explode(',', $import['imports']);
$exports = [];
foreach ($names as $name) {
$name = trim($name);
// Handle "name as alias" syntax
if (str_contains($name, ' as ')) {
[$original, $alias] = explode(' as ', $name);
$exports[] = trim($alias);
} else {
$exports[] = $name;
}
}
// If there's only one named import, assign it directly (for class constructors)
// Otherwise create an object with the named exports
if (count($exports) === 1) {
$entry_content .= "window._rsx_npm.{$global_name} = {$exports[0]};\n";
} else {
$entry_content .= "window._rsx_npm.{$global_name} = { " . implode(', ', $exports) . " };\n";
}
break;
case 'namespace':
// For namespace imports, assign directly
$import_name = $import['imports'];
$entry_content .= "window._rsx_npm.{$global_name} = {$import_name};\n";
break;
case 'default':
// For default imports, assign directly
$import_name = $import['imports'];
$entry_content .= "window._rsx_npm.{$global_name} = {$import_name};\n";
break;
}
}
// Write entry file to temp location
$temp_dir = storage_path('rsx-tmp/npm-compile');
if (!is_dir($temp_dir)) {
mkdir($temp_dir, 0755, true);
}
$entry_file = "{$temp_dir}/entry_{$cache_key}.js";
file_put_contents($entry_file, $entry_content);
// Use esbuild for bundling
$esbuild_bin = base_path('node_modules/.bin/esbuild');
if (!file_exists($esbuild_bin)) {
throw new RuntimeException('esbuild not found. Please run: npm install --save-dev esbuild');
}
// Set working directory to project root for proper module resolution
$original_cwd = getcwd();
chdir(base_path());
$esbuild_cmd = sprintf(
'%s %s --bundle --format=iife --target=es2020 --outfile=%s 2>&1',
escapeshellarg($esbuild_bin),
escapeshellarg($entry_file),
escapeshellarg($bundle_path)
);
$output = [];
$return_var = 0;
\exec_safe($esbuild_cmd, $output, $return_var);
// Restore working directory
chdir($original_cwd);
if ($return_var !== 0) {
console_debug('BUNDLE', 'ESBuild failed: ' . implode("\n", $output));
// Clean up temp file on error
@unlink($entry_file);
throw new RuntimeException('Failed to compile NPM modules: ' . implode("\n", $output));
}
// Clean up temp file
@unlink($entry_file);
// Verify bundle was created
if (!file_exists($bundle_path)) {
throw new RuntimeException("ESBuild did not produce expected output file: {$bundle_path}");
}
$size = filesize($bundle_path);
console_debug('BUNDLE', "NPM bundle compiled: {$bundle_filename} ({$size} bytes)");
return $bundle_path;
}
/**
* Generate NPM import declarations file for app bundle
* This creates const declarations that pull from window._rsx_npm
*/
protected function _generate_npm_import_declarations(): string
{
if (empty($this->npm_includes)) {
return '';
}
// Generate cache key from NPM includes
$cache_key = md5(serialize($this->npm_includes));
$filename = "npm_import_declarations_{$cache_key}.js";
$file_path = storage_path("rsx-tmp/{$filename}");
// Check if already generated
if (file_exists($file_path)) {
return $file_path;
}
// Generate content
$content = "// NPM Import Declarations for App Bundle\n";
$content .= "// Auto-generated to provide NPM modules to app bundle scope\n";
$content .= "// Cache key: {$cache_key}\n\n";
foreach ($this->npm_includes as $global_name => $import_statement) {
// Generate const declaration
$content .= "const {$global_name} = window._rsx_npm.{$global_name};\n";
$content .= "if (!{$global_name}) {\n";
$content .= " throw new Error(\n";
$content .= " 'RSX Framework Error: NPM module \"{$global_name}\" not found.\\n' +\n";
$content .= " 'Expected window._rsx_npm.{$global_name} to be defined by the vendor bundle.'\n";
$content .= " );\n";
$content .= "}\n\n";
}
// Clean up window._rsx_npm to prevent console access
$content .= "// Clean up NPM container to prevent console access\n";
$content .= "delete window._rsx_npm;\n";
// Write file
file_put_contents($file_path, $content);
return $file_path;
}
/**
* Compile JS files
*/
protected function _compile_js_files(array $files): string
{
// If we have config, write it to a temp file first
$files_to_concat = [];
if (!empty($this->config)) {
$config_content = ['window.rsxapp = window.rsxapp || {};'];
foreach ($this->config as $key => $value) {
$config_content[] = "window.rsxapp.{$key} = " . json_encode($value) . ';';
}
// Write config to temp file
$config_file = storage_path('rsx-tmp/bundle_config_' . $this->bundle_name . '.js');
file_put_contents($config_file, implode("\n", $config_content) . "\n");
$files_to_concat[] = $config_file;
}
// Transform JavaScript files if Babel is enabled and decorators are used
$babel_enabled = config('rsx.javascript.babel.transform_enabled', true);
$decorators_enabled = config('rsx.javascript.decorators', true);
if ($babel_enabled && $decorators_enabled) {
// Use the JavaScript Transformer to transpile files with decorators
// IMPORTANT: We populate $babel_file_mapping but DON'T modify $files array
// This preserves dependency sort order - we substitute babel versions during concat
foreach ($files as $file) {
// Skip temp files and already processed files
if (str_contains($file, 'storage/rsx-tmp/') || str_contains($file, 'storage/rsx-build/')) {
continue;
}
// Transform the file (will use cache if available)
try {
$transformed_code = \App\RSpade\Core\JavaScript\Js_Transformer::transform($file);
// Write transformed code to a temp file
$temp_file = storage_path('rsx-tmp/babel_' . md5($file) . '.js');
file_put_contents($temp_file, $transformed_code);
// Store mapping: original file => babel file
// During concatenation we'll use the babel version
$this->babel_file_mapping[$file] = $temp_file;
console_debug('BUNDLE', 'Transformed ' . str_replace(base_path() . '/', '', $file));
} catch (Exception $e) {
// FAIL LOUD - Never allow untransformed decorators through
throw new RuntimeException(
'JavaScript transformation failed for ' . str_replace(base_path() . '/', '', $file) .
"\nDecorators require Babel transformation to work in browsers.\n" .
'Error: ' . $e->getMessage() . "\n" .
'Fix: cd ' . base_path('app/RSpade/Core/JavaScript/resource') . ' && npm install'
);
}
}
}
// Add all the JS files
$files_to_concat = array_merge($files_to_concat, $files);
// Use Node.js script to concatenate with source map support
$output_file = storage_path('rsx-tmp/bundle_output_' . $this->bundle_name . '.js');
$concat_script = base_path('app/RSpade/Core/Bundle/resource/concat-js.js');
// Build the command
$cmd_parts = [
'node',
escapeshellarg($concat_script),
escapeshellarg($output_file),
];
foreach ($files_to_concat as $file) {
// Use babel-transformed version if it exists, otherwise use original
$file_to_use = $this->babel_file_mapping[$file] ?? $file;
// If this is a babel-transformed file, pass metadata to concat-js
// Format: babel_file_path::original_file_path
if (isset($this->babel_file_mapping[$file])) {
$cmd_parts[] = escapeshellarg($file_to_use . '::' . $file);
} else {
$cmd_parts[] = escapeshellarg($file_to_use);
}
}
$cmd = implode(' ', $cmd_parts);
// Execute the concatenation
$output = [];
$return_var = 0;
\exec_safe($cmd . ' 2>&1', $output, $return_var);
if ($return_var !== 0) {
$error_msg = implode("\n", $output);
throw new RuntimeException('Failed to concatenate JavaScript files: ' . $error_msg);
}
// Log the concatenation output
if (!empty($output)) {
foreach ($output as $line) {
console_debug('BUNDLE', "concat-js: {$line}");
}
}
// Read the concatenated result
if (!file_exists($output_file)) {
throw new RuntimeException("Concatenation script did not produce output file: {$output_file}");
}
$js = file_get_contents($output_file);
// Clean up temp files
if (!empty($this->config) && isset($config_file) && file_exists($config_file)) {
@unlink($config_file);
}
@unlink($output_file);
// Minify in production (TODO: preserve source maps when minifying)
if ($this->is_production) {
// TODO: Add minification that preserves source maps
}
return $js;
}
/**
* Compile CSS files with sourcemap support
*/
protected function _compile_css_files(array $files): string
{
// Use Node.js script to concatenate with source map support
$output_file = storage_path('rsx-tmp/css_bundle_' . $this->bundle_name . '.css');
$concat_script = base_path('app/RSpade/Core/Bundle/resource/concat-css.js');
// Build the command
$cmd_parts = [
'node',
escapeshellarg($concat_script),
escapeshellarg($output_file),
];
foreach ($files as $file) {
$cmd_parts[] = escapeshellarg($file);
}
$cmd = implode(' ', $cmd_parts);
// Execute the concatenation
$output = [];
$return_var = 0;
\exec_safe($cmd . ' 2>&1', $output, $return_var);
if ($return_var !== 0) {
$error_msg = implode("\n", $output);
throw new RuntimeException('Failed to concatenate CSS files: ' . $error_msg);
}
// Log the concatenation output
if (!empty($output)) {
foreach ($output as $line) {
console_debug('BUNDLE', "concat-css: {$line}");
}
}
// Read the concatenated result
if (!file_exists($output_file)) {
throw new RuntimeException("CSS concatenation script did not produce output file: {$output_file}");
}
$css = file_get_contents($output_file);
// Clean up temp file
@unlink($output_file);
// Minify in production
if ($this->is_production) {
// TODO: Add minification that preserves source maps
}
return $css;
}
/**
* Create JavaScript manifest definitions
*/
protected function _create_javascript_manifest(array $js_files): ?string
{
$class_definitions = [];
$manifest_files = Manifest::get_all();
// Analyze each JavaScript file for class information
foreach ($js_files as $file) {
// Skip temp files
if (str_contains($file, 'storage/rsx-tmp/')) {
continue;
}
// Get relative path for manifest lookup
$relative = str_replace(base_path() . '/', '', $file);
// Get file data from manifest
$file_data = $manifest_files[$relative] ?? null;
// All JavaScript files MUST be in manifest
if ($file_data === null) {
throw new \RuntimeException(
"JavaScript file in bundle but not in manifest (this should never happen):\n" .
"File: {$file}\n" .
"Relative: {$relative}\n" .
"Bundle: {$this->bundle_name}\n" .
"Manifest has " . count($manifest_files) . " files total"
);
}
// If file has a class, add to class definitions
if (!empty($file_data['class'])) {
$class_name = $file_data['class'];
$extends_class = $file_data['extends'] ?? null;
// Skip if extends is same as class (manifest parsing error)
if ($extends_class === $class_name) {
$extends_class = null;
}
$class_definitions[$class_name] = [
'name' => $class_name,
'extends' => $extends_class,
'decorators' => $file_data['method_decorators'] ?? null,
];
}
// Otherwise, it's a standalone function file - no manifest registration needed
}
// If no classes found, return null
if (empty($class_definitions)) {
return null;
}
// Generate JavaScript code for manifest
$js_items = [];
foreach ($class_definitions as $class_def) {
$class_name = $class_def['name'];
$extends_class = $class_def['extends'];
$decorators = $class_def['decorators'] ?? null;
// Skip 'object' as it's not a real class
if (strtolower($class_name) === 'object') {
continue;
}
// Build the manifest entry parts
$parts = [];
$parts[] = $class_name; // Class object
$parts[] = "\"{$class_name}\""; // Class name string
$parts[] = $extends_class ?: 'null'; // Parent class or null
// Add decorator data if present
if (!empty($decorators)) {
$decorator_json = json_encode($decorators, JSON_UNESCAPED_SLASHES);
$parts[] = $decorator_json;
}
// Generate the array entry
$js_items[] = '[' . implode(', ', $parts) . ']';
}
// Build the JavaScript code
$js_code = "// JavaScript Manifest - Generated by BundleCompiler\n";
$js_code .= "// Registers all classes in this bundle for runtime introspection\n";
$js_code .= "Manifest._define([\n";
$js_code .= ' ' . implode(",\n ", $js_items) . "\n";
$js_code .= "]);\n\n";
// Write to temporary file
return $this->_write_temp_file($js_code, 'js');
}
/**
* Create JavaScript runner for automatic class initialization
*/
protected function _create_javascript_runner(): string
{
$js_code = <<<'JS'
$(document).ready(async function() {
try {
console_debug('RSX_INIT', 'Document ready, starting Rsx._rsx_core_boot');
await Rsx._rsx_core_boot();
console_debug('RSX_INIT', 'Initialization complete');
} catch (error) {
console.error('[RSX_INIT] Initialization failed:', error);
console.error('[RSX_INIT] Stack:', error.stack);
throw error;
}
});
JS;
// Write to temporary file
return $this->_write_temp_file($js_code, 'js');
}
/**
* Create JavaScript route definitions from PHP controllers
*/
protected function _create_javascript_routes(): ?string
{
// Use Manifest::get_all() which returns the proper file metadata
$manifest_files = Manifest::get_all();
$routes = [];
console_debug('BUNDLE', 'Scanning for routes in ' . count($this->included_files) . ' included files');
// Scan all included files for controllers
foreach ($this->included_files as $file_path => $included) {
// Only check PHP files
if (!str_ends_with($file_path, '.php')) {
continue;
}
// Get relative path for manifest lookup
$relative = str_replace(base_path() . '/', '', $file_path);
// Check if file is in manifest
if (!isset($manifest_files[$relative])) {
continue;
}
$file_info = $manifest_files[$relative];
// Skip if no class in file
if (empty($file_info['fqcn'])) {
continue;
}
$class_name = $file_info['class'];
$fqcn = $file_info['fqcn'];
// Check if it extends Rsx_Controller_Abstract
if (!Manifest::php_is_subclass_of($class_name, 'Rsx_Controller_Abstract')) {
continue;
}
console_debug('BUNDLE', "Found controller: {$class_name}");
// Process methods with Route attributes
foreach ($file_info['public_static_methods'] ?? [] as $method_name => $method_data) {
foreach ($method_data['attributes'] ?? [] as $attr_name => $attr_instances) {
if ($attr_name === 'Route') {
// Collect all route patterns for this method (supports multiple #[Route] attributes)
foreach ($attr_instances as $instance) {
$route_pattern = $instance[0] ?? null;
if ($route_pattern) {
console_debug('BUNDLE', " Found route: {$class_name}::{$method_name} => {$route_pattern}");
// Initialize arrays if needed
if (!isset($routes[$class_name])) {
$routes[$class_name] = [];
}
if (!isset($routes[$class_name][$method_name])) {
$routes[$class_name][$method_name] = [];
}
// Append route pattern to array
$routes[$class_name][$method_name][] = $route_pattern;
}
}
}
}
}
}
// Also scan include_routes paths if specified
foreach ($this->include_routes as $route_path) {
$full_path = base_path($route_path);
if (!is_dir($full_path)) {
continue;
}
// Scan directory for PHP files
$php_files = $this->_scan_directory_recursive($full_path, 'php');
foreach ($php_files as $file) {
// Get relative path for manifest lookup
$relative = str_replace(base_path() . '/', '', $file);
// Check if file is in manifest
if (!isset($manifest_files[$relative])) {
continue;
}
$file_info = $manifest_files[$relative];
// Skip if no class in file
if (empty($file_info['fqcn'])) {
continue;
}
$class_name = $file_info['class'];
$fqcn = $file_info['fqcn'];
// Check if it extends Rsx_Controller_Abstract
if (!Manifest::php_is_subclass_of($class_name, 'Rsx_Controller_Abstract')) {
continue;
}
// Process methods with Route attributes
foreach ($file_info['public_static_methods'] ?? [] as $method_name => $method_data) {
foreach ($method_data['attributes'] ?? [] as $attr_name => $attr_instances) {
if ($attr_name === 'Route') {
// Collect all route patterns for this method (supports multiple #[Route] attributes)
foreach ($attr_instances as $instance) {
$route_pattern = $instance[0] ?? null;
if ($route_pattern) {
// Initialize arrays if needed
if (!isset($routes[$class_name])) {
$routes[$class_name] = [];
}
if (!isset($routes[$class_name][$method_name])) {
$routes[$class_name][$method_name] = [];
}
// Append route pattern to array
$routes[$class_name][$method_name][] = $route_pattern;
}
}
}
}
}
}
}
// If no routes found, return null
if (empty($routes)) {
return null;
}
// Generate JavaScript code
$js_code = "// RSX Route Definitions - Generated by BundleCompiler\n";
$js_code .= "// Provides route patterns for type-safe URL generation\n";
$js_code .= 'Rsx._define_routes(' . json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . ");\n";
// Write to temporary file
return $this->_write_temp_file($js_code, 'js');
}
/**
* Recursively scan directory for files with specific extension
*/
protected function _scan_directory_recursive(string $path, string $extension): array
{
$files = [];
if (!is_dir($path)) {
return $files;
}
$directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS);
$iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === $extension) {
$files[] = $file->getPathname();
}
}
return $files;
}
}