Files
rspade_system/app/RSpade/Commands/Rsx/Ssr_Fpc_Create_Command.php
root f6fac6c4bc Fix bin/publish: copy docs.dist from project root
Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 02:08:33 +00:00

280 lines
11 KiB
PHP
Executable File

<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Commands\Rsx;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
use Illuminate\Support\Facades\Redis;
use App\RSpade\Core\Bootstrap\ManifestKernel;
/**
* RSX SSR Full Page Cache (FPC) Create Command
* ==============================================
*
* PURPOSE:
* Generates static, pre-rendered HTML cache for routes marked with #[Static_Page] attribute.
* Uses Playwright to render pages in headless Chrome, capturing the fully-rendered DOM state
* for optimal SEO and performance.
*
* HOW IT WORKS:
* 1. Launches headless Chromium browser via Playwright
* 2. Navigates to the specified route with FPC generation headers
* 3. Waits for _debug_ready event (all components initialized)
* 4. Waits for network idle + 10ms buffer
* 5. Captures full DOM or redirect response
* 6. Generates ETag from build_key + URL + content hash
* 7. Stores in Redis: ssr_fpc:{build_key}:{url_hash}
*
* KEY FEATURES:
* - Exclusive lock (GENERATE_STATIC_CACHE) prevents concurrent generation
* - Strips GET parameters from URLs (static pages ignore query strings)
* - Handles redirect responses (caches 302 location without following)
* - Build key integration (auto-invalidates cache on deployment)
* - Comprehensive error logging to storage/logs/ssr-fpc-errors.log
*
* DESIGN GOALS:
* - SEO-optimized static pages for unauthenticated users
* - Fail-loud approach (fatal exception if generation fails)
* - Cache key includes build_key for automatic invalidation
* - Development only tool (production uses pre-generated cache)
*
* USAGE EXAMPLES:
* php artisan rsx:ssr_fpc:create / # Cache homepage
* php artisan rsx:ssr_fpc:create /about # Cache about page
* php artisan rsx:ssr_fpc:create /products/view/123 # Cache dynamic route
*
* FUTURE ROADMAP:
* - Support for --from-sitemap to generate all pages from sitemap.xml
* - Shared private key for FPC client authentication
* - External service for distributed cache generation
* - Parallelization for faster multi-page generation
* - Programmatic cache reset hooks (CMS updates, blog posts)
*
* IMPLEMENTATION DETAILS:
* - Uses X-RSpade-FPC-Client header to identify cache generation requests
* - Playwright script located in resource/playwright/generate-static-cache.js
* - Cache stored as JSON: {url, code, page_dom/redirect, build_key, etag, generated_at}
* - ETag is first 30 chars of SHA1(build_key + url + content)
* - Redis key format: ssr_fpc:{build_key}:{sha1(url)}
*
* SECURITY:
* - Only available in local/development/testing environments
* - Throws fatal error if attempted in production
* - FPC bypass header prevents cache serving during generation
*/
class Ssr_Fpc_Create_Command extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'rsx:ssr_fpc:create
{url : The URL to generate static cache for (e.g., /about, /products)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate static page cache for SSR FPC system (development only)';
/**
* Execute the console command.
*/
public function handle()
{
// Check environment - throw fatal error in production
if (app()->environment('production')) {
throw new \RuntimeException('FATAL: rsx:ssr_fpc:create command is not available in production environment. This is a development-only tool.');
}
// Check if SSR FPC is enabled
if (!config('rsx.ssr_fpc.enabled', false)) {
$this->error('SSR FPC is disabled. Enable it in config/rsx.php or set SSR_FPC_ENABLED=true in .env');
return 1;
}
// Get the URL to generate cache for
$url = $this->argument('url');
// Strip query parameters from URL (static pages ignore query strings)
$url = parse_url($url, PHP_URL_PATH) ?: $url;
// Ensure URL starts with /
if (!str_starts_with($url, '/')) {
$url = '/' . $url;
}
$this->info("Generating static cache for: {$url}");
// Check if Playwright script exists
$playwright_script = base_path('app/RSpade/Commands/Rsx/resource/playwright/generate-static-cache.js');
if (!file_exists($playwright_script)) {
$this->error("❌ Playwright script not found: {$playwright_script}");
$this->error('Please create the script or check your installation');
return 1;
}
// Check if node/npm is available
$node_check = new Process(['node', '--version']);
$node_check->run();
if (!$node_check->isSuccessful()) {
$this->error('❌ Node.js is not installed or not in PATH');
return 1;
}
// Check if playwright is installed
$playwright_check = new Process(['node', '-e', "require('playwright')"], base_path());
$playwright_check->run();
if (!$playwright_check->isSuccessful()) {
$this->warn('⚠️ Playwright not installed. Installing now...');
$npm_install = new Process(['npm', 'install', 'playwright'], base_path());
$npm_install->run(function ($type, $buffer) {
echo $buffer;
});
if (!$npm_install->isSuccessful()) {
$this->error('❌ Failed to install Playwright');
return 1;
}
$this->info('✅ Playwright installed');
$this->info('');
}
// Check if chromium browser is installed and up to date
$browser_check_script = "const {chromium} = require('playwright'); chromium.launch({headless:true}).then(b => {b.close(); process.exit(0);}).catch(e => {console.error(e.message); process.exit(1);});";
$browser_check = new Process(['node', '-e', $browser_check_script], base_path(), $_ENV, null, 10);
$browser_check->run();
if (!$browser_check->isSuccessful()) {
$error_output = $browser_check->getErrorOutput() . $browser_check->getOutput();
// Check if it's a browser not installed or out of date error
if (str_contains($error_output, "Executable doesn't exist") ||
str_contains($error_output, "browserType.launch") ||
str_contains($error_output, "Playwright was just installed or updated")) {
$this->info('Installing/updating Chromium browser...');
$browser_install = new Process(['npx', 'playwright', 'install', 'chromium'], base_path());
$browser_install->setTimeout(300); // 5 minute timeout for download
$browser_install->run(function ($type, $buffer) {
// Silent - downloads can be verbose
});
if (!$browser_install->isSuccessful()) {
$this->error('❌ Failed to install Chromium browser');
$this->error('Run manually: npx playwright install chromium');
return 1;
}
$this->info('✅ Chromium browser installed/updated');
$this->info('');
} else {
$this->error('❌ Browser check failed: ' . trim($error_output));
return 1;
}
}
// Get timeout from config
$timeout = config('rsx.ssr_fpc.generation_timeout', 30000);
// Build command arguments
$command_args = ['node', $playwright_script, $url, "--timeout={$timeout}"];
$env = array_merge($_ENV, [
'BASE_URL' => config('app.url')
]);
// Convert timeout from milliseconds to seconds for Process timeout
// Add 10 seconds buffer to the Process timeout to allow Playwright to timeout first
$process_timeout = ($timeout / 1000) + 10;
// Release the application lock before running Playwright to prevent lock contention
// The artisan command holds a WRITE lock which would block the web request's READ lock
\App\RSpade\Core\Bootstrap\RsxBootstrap::temporarily_release_lock();
$process = new Process(
$command_args,
base_path(),
$env,
null,
$process_timeout
);
$output = '';
$process->run(function ($type, $buffer) use (&$output) {
echo $buffer;
$output .= $buffer;
});
if (!$process->isSuccessful()) {
$this->error('❌ Failed to generate static cache');
$this->error('Check storage/logs/ssr-fpc-errors.log for details');
return 1;
}
// Parse JSON output from Playwright script
try {
$result = json_decode($output, true);
if (!$result) {
throw new \Exception('Invalid JSON response from Playwright');
}
} catch (\Exception $e) {
$this->error('❌ Failed to parse Playwright output: ' . $e->getMessage());
$this->error('Raw output: ' . $output);
return 1;
}
// Get build key from manifest
$build_key = \App\RSpade\Core\Manifest\Manifest::get_build_key();
// Generate Redis cache key
$url_hash = sha1($url);
$redis_key = "ssr_fpc:{$build_key}:{$url_hash}";
// Generate ETag (first 30 chars of SHA1)
$content_for_etag = $build_key . $url . ($result['page_dom'] ?? $result['redirect'] ?? '');
$etag = substr(sha1($content_for_etag), 0, 30);
// Build cache entry
$cache_entry = [
'url' => $url,
'code' => $result['code'],
'build_key' => $build_key,
'etag' => $etag,
'generated_at' => time(),
];
if ($result['code'] >= 300 && $result['code'] < 400) {
// Redirect response
$cache_entry['redirect'] = $result['redirect'];
$cache_entry['page_dom'] = null;
} else {
// Normal response
$cache_entry['page_dom'] = $result['page_dom'];
$cache_entry['redirect'] = null;
}
// Store in Redis as JSON
try {
Redis::set($redis_key, json_encode($cache_entry));
$this->info("✅ Static cache generated successfully");
$this->info(" Redis key: {$redis_key}");
$this->info(" ETag: {$etag}");
$this->info(" Status: {$result['code']}");
} catch (\Exception $e) {
$this->error('❌ Failed to store cache in Redis: ' . $e->getMessage());
return 1;
}
return 0;
}
}