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>
280 lines
11 KiB
PHP
Executable File
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;
|
|
}
|
|
}
|