Standardize settings file naming and relocate documentation files Fix code quality violations from rsx:check Reorganize user_management directory into logical subdirectories Move Quill Bundle to core and align with Tom Select pattern Simplify Site Settings page to focus on core site information Complete Phase 5: Multi-tenant authentication with login flow and site selection Add route query parameter rule and synchronize filename validation logic Fix critical bug in UpdateNpmCommand causing missing JavaScript stubs Implement filename convention rule and resolve VS Code auto-rename conflict Implement js-sanitizer RPC server to eliminate 900+ Node.js process spawns Implement RPC server architecture for JavaScript parsing WIP: Add RPC server infrastructure for JS parsing (partial implementation) Update jqhtml terminology from destroy to stop, fix datagrid DOM preservation Add JQHTML-CLASS-01 rule and fix redundant class names Improve code quality rules and resolve violations Remove legacy fatal error format in favor of unified 'fatal' error type Filter internal keys from window.rsxapp output Update button styling and comprehensive form/modal documentation Add conditional fly-in animation for modals Fix non-deterministic bundle compilation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
519 lines
16 KiB
PHP
Executable File
519 lines
16 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\RSpade\Integrations\Jqhtml;
|
|
|
|
use App\RSpade\Integrations\Jqhtml\Jqhtml_Exception_ViewException;
|
|
|
|
/**
|
|
* JqhtmlWebpackCompiler - Compiles JQHTML templates to JavaScript during bundle build
|
|
*
|
|
* This service handles the compilation of .jqhtml template files into JavaScript
|
|
* during the bundle build process. It uses the @jqhtml/parser NPM package to
|
|
* perform the compilation and caches the results based on file modification times.
|
|
*
|
|
* Features:
|
|
* - Uses @jqhtml/parser for template compilation
|
|
* - Caches compiled templates based on file mtime
|
|
* - Throws fatal errors on compilation failures (fail loud)
|
|
* - Integrates with the bundle compilation pipeline
|
|
*/
|
|
#[Instantiatable]
|
|
class JqhtmlWebpackCompiler
|
|
{
|
|
/**
|
|
* RPC server script path
|
|
*/
|
|
protected const RPC_SERVER_SCRIPT = 'app/RSpade/Integrations/Jqhtml/resource/jqhtml-compile-server.js';
|
|
|
|
/**
|
|
* RPC server socket path
|
|
*/
|
|
protected const RPC_SOCKET = 'storage/rsx-tmp/jqhtml-compile-server.sock';
|
|
|
|
/**
|
|
* RPC server process
|
|
*/
|
|
protected static $rpc_server_process = null;
|
|
|
|
/**
|
|
* RPC request ID counter
|
|
*/
|
|
protected static $request_id = 0;
|
|
|
|
/**
|
|
* Path to jqhtml-compile binary for package validation (RPC server used for actual compilation)
|
|
*/
|
|
protected string $compiler_path;
|
|
|
|
/**
|
|
* Cache directory for compiled templates
|
|
*/
|
|
protected string $cache_dir;
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
public function __construct()
|
|
{
|
|
// Use official jqhtml CLI compiler from npm package
|
|
$this->compiler_path = base_path('node_modules/@jqhtml/parser/bin/jqhtml-compile');
|
|
$this->cache_dir = storage_path('rsx-tmp/jqhtml-cache');
|
|
|
|
// Ensure cache directory exists
|
|
if (!is_dir($this->cache_dir)) {
|
|
mkdir($this->cache_dir, 0755, true);
|
|
}
|
|
|
|
// Validate compiler exists - MUST exist
|
|
if (!file_exists($this->compiler_path)) {
|
|
throw new \RuntimeException(
|
|
"Official JQHTML CLI compiler not found at: {$this->compiler_path}. " .
|
|
"Run 'npm install @jqhtml/parser@^2.2.59' to install the official CLI compiler."
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compile a single JQHTML template file
|
|
*
|
|
* @param string $file_path Path to .jqhtml file
|
|
* @return string Compiled JavaScript code
|
|
* @throws \RuntimeException On compilation failure
|
|
*/
|
|
public function compile_file(string $file_path): string
|
|
{
|
|
if (!file_exists($file_path)) {
|
|
throw new \RuntimeException("JQHTML template not found: {$file_path}");
|
|
}
|
|
|
|
// Get file modification time for cache key
|
|
$mtime = filemtime($file_path);
|
|
$cache_key = md5($file_path) . '_' . $mtime;
|
|
$cache_file = $this->cache_dir . '/' . $cache_key . '.js';
|
|
|
|
// Check if cached version exists
|
|
if (file_exists($cache_file)) {
|
|
console_debug("JQHTML", "Using cached JQHTML template: {$file_path}");
|
|
return file_get_contents($cache_file);
|
|
}
|
|
|
|
console_debug("JQHTML", "Compiling JQHTML template: {$file_path}");
|
|
|
|
// Compile via RPC server
|
|
$compiled_js = static::_compile_via_rpc($file_path);
|
|
|
|
// Don't add any comments - they break sourcemap line offsets
|
|
// Just use the compiler output as-is
|
|
$wrapped_js = $compiled_js;
|
|
|
|
// Ensure proper newline at end
|
|
if (!str_ends_with($wrapped_js, "\n")) {
|
|
$wrapped_js .= "\n";
|
|
}
|
|
|
|
// Cache the compiled result
|
|
file_put_contents($cache_file, $wrapped_js);
|
|
|
|
// Clean up old cache files for this template
|
|
$this->cleanup_old_cache($file_path, $cache_key);
|
|
|
|
return $wrapped_js;
|
|
}
|
|
|
|
/**
|
|
* Compile multiple JQHTML template files
|
|
*
|
|
* @param array $files Array of file paths
|
|
* @return array Compiled JavaScript code keyed by file path
|
|
*/
|
|
public function compile_files(array $files): array
|
|
{
|
|
$compiled = [];
|
|
|
|
foreach ($files as $file) {
|
|
try {
|
|
$compiled[$file] = $this->compile_file($file);
|
|
} catch (\Exception $e) {
|
|
// FAIL LOUD - don't continue on error
|
|
throw new \RuntimeException(
|
|
"Failed to compile JQHTML templates: " . $e->getMessage()
|
|
);
|
|
}
|
|
}
|
|
|
|
return $compiled;
|
|
}
|
|
|
|
/**
|
|
* Extract component name from file path
|
|
*
|
|
* @param string $file_path Path to .jqhtml file
|
|
* @return string Component name
|
|
*/
|
|
protected function extract_component_name(string $file_path): string
|
|
{
|
|
// Remove base path and extension
|
|
$relative = str_replace(base_path() . '/', '', $file_path);
|
|
$relative = preg_replace('/\.jqhtml$/i', '', $relative);
|
|
|
|
// Convert path to component name (e.g., rsx/app/components/MyComponent)
|
|
// to MyComponent or components/MyComponent
|
|
$parts = explode('/', $relative);
|
|
|
|
// Use the filename as the component name
|
|
return basename($relative);
|
|
}
|
|
|
|
/**
|
|
* Clean up old cache files for a template
|
|
*
|
|
* @param string $file_path Original template path
|
|
* @param string $current_cache_key Current cache key to keep
|
|
*/
|
|
protected function cleanup_old_cache(string $file_path, string $current_cache_key): void
|
|
{
|
|
$file_hash = md5($file_path);
|
|
$pattern = $this->cache_dir . '/' . $file_hash . '_*.js';
|
|
|
|
foreach (glob($pattern) as $cache_file) {
|
|
$cache_key = basename($cache_file, '.js');
|
|
if ($cache_key !== $current_cache_key) {
|
|
unlink($cache_file);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all cached templates
|
|
*/
|
|
public function clear_cache(): void
|
|
{
|
|
$pattern = $this->cache_dir . '/*.js';
|
|
foreach (glob($pattern) as $cache_file) {
|
|
unlink($cache_file);
|
|
}
|
|
|
|
console_debug("JQHTML", "Cleared JQHTML template cache");
|
|
}
|
|
|
|
/**
|
|
* Get cache statistics
|
|
*
|
|
* @return array Cache statistics
|
|
*/
|
|
public function get_cache_stats(): array
|
|
{
|
|
$pattern = $this->cache_dir . '/*.js';
|
|
$files = glob($pattern);
|
|
|
|
$total_size = 0;
|
|
foreach ($files as $file) {
|
|
$total_size += filesize($file);
|
|
}
|
|
|
|
return [
|
|
'cache_dir' => $this->cache_dir,
|
|
'cached_files' => count($files),
|
|
'total_size' => $total_size,
|
|
'total_size_human' => $this->format_bytes($total_size)
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Format bytes to human readable
|
|
*
|
|
* @param int $bytes
|
|
* @return string
|
|
*/
|
|
protected function format_bytes(int $bytes): string
|
|
{
|
|
$units = ['B', 'KB', 'MB', 'GB'];
|
|
$i = 0;
|
|
|
|
while ($bytes >= 1024 && $i < count($units) - 1) {
|
|
$bytes /= 1024;
|
|
$i++;
|
|
}
|
|
|
|
return round($bytes, 2) . ' ' . $units[$i];
|
|
}
|
|
|
|
/**
|
|
* Start RPC server for JQHTML compilation
|
|
* Lazy initialization - called automatically on first compile
|
|
*/
|
|
public static function start_rpc_server(): void
|
|
{
|
|
$socket_path = base_path(self::RPC_SOCKET);
|
|
$server_script = base_path(self::RPC_SERVER_SCRIPT);
|
|
|
|
if (!file_exists($server_script)) {
|
|
throw new \RuntimeException("JQHTML Compile RPC server script not found at {$server_script}");
|
|
}
|
|
|
|
// If socket exists, check if it's stale
|
|
if (file_exists($socket_path)) {
|
|
// Try to ping the existing server
|
|
if (static::ping_rpc_server()) {
|
|
// Server already running and responsive
|
|
return;
|
|
}
|
|
|
|
// Socket exists but server not responding - force cleanup
|
|
static::stop_rpc_server(force: true);
|
|
}
|
|
|
|
// Ensure socket directory exists
|
|
$socket_dir = dirname($socket_path);
|
|
if (!is_dir($socket_dir)) {
|
|
mkdir($socket_dir, 0755, true);
|
|
}
|
|
|
|
// Start RPC server
|
|
$process = new \Symfony\Component\Process\Process([
|
|
'node',
|
|
$server_script,
|
|
'--socket=' . $socket_path
|
|
]);
|
|
|
|
$process->setWorkingDirectory(base_path());
|
|
$process->setTimeout(null); // No timeout for long-running server
|
|
$process->start();
|
|
|
|
static::$rpc_server_process = $process;
|
|
|
|
// Wait for server to be ready (ping until it responds)
|
|
$max_wait_ms = 10000; // 10 seconds
|
|
$wait_interval_ms = 50;
|
|
$iterations = $max_wait_ms / $wait_interval_ms;
|
|
|
|
for ($i = 0; $i < $iterations; $i++) {
|
|
usleep($wait_interval_ms * 1000); // Convert to microseconds
|
|
|
|
if (static::ping_rpc_server()) {
|
|
// Register shutdown handler to clean up server
|
|
register_shutdown_function([self::class, 'stop_rpc_server']);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Timeout waiting for server
|
|
throw new \RuntimeException(
|
|
"JQHTML Compile RPC server failed to start within {$max_wait_ms}ms.\n" .
|
|
"Check that Node.js and @jqhtml/parser are installed."
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Ping the RPC server to check if it's alive
|
|
*
|
|
* @return bool True if server responds, false otherwise
|
|
*/
|
|
public static function ping_rpc_server(): bool
|
|
{
|
|
$socket_path = base_path(self::RPC_SOCKET);
|
|
|
|
if (!file_exists($socket_path)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1);
|
|
if (!$socket) {
|
|
return false;
|
|
}
|
|
|
|
// Set blocking mode for reliable reads/writes
|
|
stream_set_blocking($socket, true);
|
|
|
|
static::$request_id++;
|
|
$request = json_encode([
|
|
'id' => static::$request_id,
|
|
'method' => 'ping'
|
|
]) . "\n";
|
|
|
|
fwrite($socket, $request);
|
|
|
|
$response = fgets($socket);
|
|
fclose($socket);
|
|
|
|
if (!$response) {
|
|
return false;
|
|
}
|
|
|
|
$result = json_decode($response, true);
|
|
return isset($result['result']) && $result['result'] === 'pong';
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop the RPC server
|
|
*
|
|
* @param bool $force If true, forcefully kill the server (used for cleanup)
|
|
*/
|
|
public static function stop_rpc_server(bool $force = false): void
|
|
{
|
|
$socket_path = base_path(self::RPC_SOCKET);
|
|
|
|
if ($force) {
|
|
// Force cleanup: send shutdown, wait briefly, then SIGTERM if needed
|
|
if (file_exists($socket_path)) {
|
|
try {
|
|
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1);
|
|
if ($socket) {
|
|
// Set blocking mode for reliable writes
|
|
stream_set_blocking($socket, true);
|
|
|
|
static::$request_id++;
|
|
$request = json_encode([
|
|
'id' => static::$request_id,
|
|
'method' => 'shutdown'
|
|
]) . "\n";
|
|
|
|
fwrite($socket, $request);
|
|
fclose($socket);
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Ignore errors during force shutdown
|
|
}
|
|
|
|
// Wait briefly for graceful shutdown
|
|
usleep(100000); // 100ms
|
|
|
|
// Force remove socket
|
|
@unlink($socket_path);
|
|
}
|
|
|
|
// Kill process if still running
|
|
if (static::$rpc_server_process && static::$rpc_server_process->isRunning()) {
|
|
static::$rpc_server_process->stop(0, SIGTERM);
|
|
}
|
|
} else {
|
|
// Graceful shutdown: just send shutdown command
|
|
if (file_exists($socket_path)) {
|
|
try {
|
|
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1);
|
|
if ($socket) {
|
|
// Set blocking mode for reliable writes
|
|
stream_set_blocking($socket, true);
|
|
|
|
static::$request_id++;
|
|
$request = json_encode([
|
|
'id' => static::$request_id,
|
|
'method' => 'shutdown'
|
|
]) . "\n";
|
|
|
|
fwrite($socket, $request);
|
|
fclose($socket);
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Ignore errors during graceful shutdown
|
|
}
|
|
}
|
|
}
|
|
|
|
static::$rpc_server_process = null;
|
|
}
|
|
|
|
/**
|
|
* Compile file via RPC server
|
|
*
|
|
* @param string $file_path Path to file to compile
|
|
* @return string Compiled code
|
|
*/
|
|
protected static function _compile_via_rpc(string $file_path): string
|
|
{
|
|
// Start RPC server on first use (lazy initialization)
|
|
if (static::$rpc_server_process === null) {
|
|
static::start_rpc_server();
|
|
}
|
|
|
|
$socket_path = base_path(self::RPC_SOCKET);
|
|
|
|
if (!file_exists($socket_path)) {
|
|
throw new \RuntimeException("JQHTML Compile RPC server socket not found at {$socket_path}");
|
|
}
|
|
|
|
// Connect to RPC server
|
|
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 5);
|
|
if (!$socket) {
|
|
throw new \RuntimeException("Failed to connect to JQHTML Compile RPC server: {$errstr}");
|
|
}
|
|
|
|
// Set blocking mode for reliable reads/writes
|
|
stream_set_blocking($socket, true);
|
|
|
|
// Send compile request
|
|
static::$request_id++;
|
|
$request = json_encode([
|
|
'id' => static::$request_id,
|
|
'method' => 'compile',
|
|
'files' => [
|
|
[
|
|
'path' => $file_path,
|
|
'format' => 'iife',
|
|
'sourcemap' => true
|
|
]
|
|
]
|
|
]) . "\n";
|
|
|
|
fwrite($socket, $request);
|
|
|
|
// Read response
|
|
$response = fgets($socket);
|
|
fclose($socket);
|
|
|
|
if (!$response) {
|
|
throw new \RuntimeException("JQHTML Compile RPC server returned empty response for {$file_path}");
|
|
}
|
|
|
|
$result = json_decode($response, true);
|
|
|
|
if (!$result || !isset($result['results'])) {
|
|
throw new \RuntimeException(
|
|
"JQHTML Compile RPC server returned invalid response for {$file_path}:\n" . $response
|
|
);
|
|
}
|
|
|
|
$file_result = $result['results'][$file_path] ?? null;
|
|
|
|
if (!$file_result) {
|
|
throw new \RuntimeException("JQHTML Compile RPC server did not return result for {$file_path}");
|
|
}
|
|
|
|
// Handle error response
|
|
if ($file_result['status'] === 'error' && isset($file_result['error'])) {
|
|
$error = $file_result['error'];
|
|
$message = $error['message'] ?? 'Unknown error';
|
|
$line = $error['line'] ?? null;
|
|
$column = $error['column'] ?? null;
|
|
|
|
// Throw appropriate exception type
|
|
if ($line && $column) {
|
|
throw new Jqhtml_Exception_ViewException(
|
|
"JQHTML compilation failed:\n{$message}",
|
|
$file_path,
|
|
$line,
|
|
$column
|
|
);
|
|
}
|
|
|
|
throw new \RuntimeException(
|
|
"JQHTML compilation failed for {$file_path}:\n{$message}"
|
|
);
|
|
}
|
|
|
|
// Success - return compiled code
|
|
if ($file_result['status'] === 'success' && isset($file_result['result'])) {
|
|
return $file_result['result'];
|
|
}
|
|
|
|
// Unknown response format
|
|
throw new \RuntimeException(
|
|
"JQHTML Compile RPC server returned unexpected response for {$file_path}:\n" .
|
|
json_encode($file_result, JSON_PRETTY_PRINT)
|
|
);
|
|
}
|
|
} |