Files
rspade_system/app/RSpade/Integrations/Jqhtml/JqhtmlWebpackCompiler.php
2025-12-03 21:28:08 +00:00

524 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);
// Extract template variable name and append registration call
// CRITICAL: Do NOT add extra newlines - they break sourcemap line offsets
// The registration is appended AFTER the sourcemap comment so it doesn't affect mappings
if (preg_match('/var\s+(template_\w+)\s*=/', $compiled_js, $matches)) {
$template_var = $matches[1];
// Append registration on same line as end of sourcemap (no extra newlines)
$compiled_js = rtrim($compiled_js) . "\njqhtml.register_template({$template_var});";
}
$wrapped_js = $compiled_js;
// Ensure exactly one newline at end (no extra)
$wrapped_js = rtrim($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)
);
}
}