Framework updates

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2026-03-12 19:09:07 +00:00
parent 3294fc7337
commit daa9bb2fb1
47 changed files with 2495 additions and 525 deletions

View File

@@ -297,6 +297,35 @@ class Dispatcher
Debugger::console_debug('DISPATCH', 'Matched route to ' . $handler_class . '::' . $handler_method . ' params: ' . json_encode($params));
// --- FPC Detection ---
// Check if route has #[FPC] attribute and conditions are met for caching
$has_fpc = false;
try {
$fpc_metadata = Manifest::php_get_metadata_by_fqcn($handler_class);
$fpc_method_data = $fpc_metadata['public_static_methods'][$handler_method] ?? null;
if ($fpc_method_data && isset($fpc_method_data['attributes']['FPC'])) {
// FPC only active for unauthenticated GET requests without POST/FILE data
if ($route_method === 'GET' && empty($_POST) && empty($_FILES)) {
\App\RSpade\Core\Session\Session::init();
if (!\App\RSpade\Core\Session\Session::is_logged_in()) {
$has_fpc = true;
// Blank all cookies except session to prevent tainted output
foreach ($_COOKIE as $key => $value) {
if ($key !== 'rsx') {
unset($_COOKIE[$key]);
}
}
}
}
}
} catch (\Throwable $e) {
console_debug('FPC', 'Metadata lookup failed: ' . $e->getMessage());
}
// --- End FPC Detection ---
// Set current controller and action in Rsx for tracking
$route_type = $route_match['type'] ?? 'standard';
\App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method, $params, $route_type);
@@ -323,6 +352,12 @@ class Dispatcher
// Convert result to response
$response = static::__build_response($result);
// Add FPC header if conditions were met — signals the FPC proxy to cache this response
if ($has_fpc && $response instanceof \Symfony\Component\HttpFoundation\Response) {
$response->headers->set('X-RSpade-FPC', '1');
$response->headers->remove('Set-Cookie');
}
// Apply response transformations (HEAD body stripping, etc.)
return static::__transform_response($response, $original_method);
}

105
app/RSpade/Core/FPC/Rsx_FPC.php Executable file
View File

@@ -0,0 +1,105 @@
<?php
namespace App\RSpade\Core\FPC;
use App\RSpade\Core\Manifest\Manifest;
/**
* Full Page Cache management utility
*
* Provides methods to clear FPC entries from Redis.
* The FPC is a Node.js reverse proxy that caches responses
* marked with #[FPC] attribute in Redis.
*
* Cache key format: fpc:{build_key}:{sha1(url)}
* Redis DB: 0 (shared cache database with LRU eviction)
*/
class Rsx_FPC
{
/**
* Clear all FPC cache entries for the current build key
*
* @return int Number of deleted entries
*/
public static function clear(): int
{
$redis = self::_get_redis();
if (!$redis) {
return 0;
}
$build_key = Manifest::get_build_key();
$pattern = "fpc:{$build_key}:*";
$count = 0;
// Use SCAN to avoid blocking Redis with KEYS
$iterator = null;
do {
$keys = $redis->scan($iterator, $pattern, 100);
if ($keys !== false && count($keys) > 0) {
$count += $redis->del($keys);
}
} while ($iterator > 0);
return $count;
}
/**
* Clear FPC cache for a specific URL
*
* @param string $url The URL path with optional query string (e.g., '/about' or '/search?q=test')
* @return bool True if entry was deleted
*/
public static function clear_url(string $url): bool
{
$redis = self::_get_redis();
if (!$redis) {
return false;
}
$build_key = Manifest::get_build_key();
// Parse and sort query params to match the proxy's key generation
$parsed = parse_url($url);
$path = $parsed['path'] ?? $url;
if (!empty($parsed['query'])) {
parse_str($parsed['query'], $params);
ksort($params);
$full_url = $path . '?' . http_build_query($params);
} else {
$full_url = $path;
}
$hash = sha1($full_url);
$key = "fpc:{$build_key}:{$hash}";
return $redis->del($key) > 0;
}
/**
* Get Redis connection for FPC operations
* Uses DB 0 (cache DB with LRU eviction)
*/
private static function _get_redis(): ?\Redis
{
static $redis = null;
if ($redis) {
return $redis;
}
$redis = new \Redis();
$host = env('REDIS_HOST', '127.0.0.1');
$port = (int) env('REDIS_PORT', 6379);
try {
$redis->connect($host, $port, 2.0);
$redis->select(0);
return $redis;
} catch (\Throwable $e) {
$redis = null;
return null;
}
}
}

View File

@@ -222,6 +222,16 @@ class Rsx {
return !window.rsxapp.debug;
}
// Returns true when running inside the SSR Node server
static is_ssr() {
return !!window.__SSR__;
}
// Returns true in the browser when hydrating an SSR-rendered page
static is_ssr_hydrate() {
return !!(window.rsxapp && window.rsxapp.page_data && window.rsxapp.page_data.ssr);
}
/**
* Get the current logged-in user model instance
* Returns the hydrated ORM model if available, or the raw data object
@@ -717,24 +727,27 @@ class Rsx {
Rsx.trigger(phase.event);
}
// Ui refresh callbacks
Rsx.trigger_refresh();
// All phases complete
console_debug('RSX_INIT', 'Initialization complete');
// TODO: Find a good wait to wait for all jqhtml components to load, then trigger on_ready and on('ready') emulating the top level last syntax that jqhtml components operateas, but as a standard js class (such as a page class). The biggest question is, how do we efficiently choose only the top level jqhtml components. do we only consider components cretaed directly on blade templates? that seams reasonable...
// Skip browser-only post-boot steps in SSR
if (!Rsx.is_ssr()) {
// Ui refresh callbacks
Rsx.trigger_refresh();
// Trigger _debug_ready event - this is ONLY for tooling like rsx:debug
// DO NOT use this in application code - use on_app_ready() phase instead
// This event exists solely for debugging tools that need to run after full initialization
Rsx.trigger('_debug_ready');
// TODO: Find a good wait to wait for all jqhtml components to load, then trigger on_ready and on('ready') emulating the top level last syntax that jqhtml components operateas, but as a standard js class (such as a page class). The biggest question is, how do we efficiently choose only the top level jqhtml components. do we only consider components cretaed directly on blade templates? that seams reasonable...
// Restore scroll position on page refresh
// Use requestAnimationFrame to ensure DOM is fully rendered after SPA action completes
requestAnimationFrame(() => {
Rsx._restore_scroll_on_refresh();
});
// Trigger _debug_ready event - this is ONLY for tooling like rsx:debug
// DO NOT use this in application code - use on_app_ready() phase instead
// This event exists solely for debugging tools that need to run after full initialization
Rsx.trigger('_debug_ready');
// Restore scroll position on page refresh
// Use requestAnimationFrame to ensure DOM is fully rendered after SPA action completes
requestAnimationFrame(() => {
Rsx._restore_scroll_on_refresh();
});
}
}
/**

View File

@@ -13,6 +13,7 @@
*/
class Rsx_Behaviors {
static _on_framework_core_init() {
if (Rsx.is_ssr()) return;
Rsx_Behaviors._init_ignore_invalid_anchor_links();
Rsx_Behaviors._trim_copied_text();
}

View File

@@ -20,6 +20,7 @@
*/
class Rsx_Droppable {
static _on_framework_core_init() {
if (Rsx.is_ssr()) return;
Rsx_Droppable._init();
}

View File

@@ -7,6 +7,7 @@ class Rsx_Init {
* Initializes the core environment and runs basic sanity checks
*/
static _on_framework_core_init() {
if (Rsx.is_ssr()) return;
if (!Rsx.is_prod()) {
Rsx_Init.__environment_checks();
}

View File

@@ -12,6 +12,8 @@ class Rsx_View_Transitions {
* Checks for View Transitions API support and enables if available
*/
static _on_framework_core_init() {
if (Rsx.is_ssr()) return;
// Check if View Transitions API is supported
if (!document.startViewTransition) {
console_debug('VIEW_TRANSITIONS', 'View Transitions API not supported, skipping');

View File

@@ -92,6 +92,8 @@ class Width_Group {
* Initialize jQuery extensions
*/
static _on_framework_core_define() {
if (Rsx.is_ssr()) return;
/**
* Add elements to a named width group
* @param {string} group_name - Name of the width group

View File

@@ -40,6 +40,7 @@ class RsxLocks
const LOCK_BUNDLE_BUILD = 'BUNDLE_BUILD'; // Bundle compilation operations
const LOCK_MIGRATION = 'MIGRATION'; // Database migration operations
const LOCK_FILE_WRITE = 'FILE_WRITE'; // File upload/storage operations
const LOCK_SSR_RENDER = 'SSR_RENDER'; // SSR server lifecycle + render operations
// Site-specific lock prefix (appended with site_id)
const LOCK_SITE_PREFIX = 'SITE_'; // e.g., SITE_1, SITE_2, etc.

View File

@@ -146,6 +146,9 @@ class _Manifest_Cache_Helper
}
file_put_contents($cache_file, $php_content);
// Write build key to separate file for non-PHP consumers (FPC proxy, etc.)
file_put_contents(dirname($cache_file) . '/build_key', Manifest::$data['hash']);
}
public static function _validate_cached_data()
@@ -364,6 +367,68 @@ class _Manifest_Cache_Helper
"To: public static function {$method_name}(...)\n"
);
}
// Check FPC attribute constraints
$has_fpc = false;
$has_spa = false;
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
if ($attr_name === 'FPC' || str_ends_with($attr_name, '\\FPC')) {
$has_fpc = true;
}
if ($attr_name === 'SPA' || str_ends_with($attr_name, '\\SPA')) {
$has_spa = true;
}
}
if ($has_fpc) {
$class_name = $metadata['class'] ?? 'Unknown';
if ($has_ajax_endpoint) {
throw new \RuntimeException(
"#[FPC] cannot be used on Ajax endpoints\n" .
"Class: {$class_name}\n" .
"Method: {$method_name}\n" .
"File: {$file_path}\n\n" .
"#[FPC] marks a route for full page caching. Ajax endpoints return JSON\n" .
"data, not HTML pages.\n\n" .
"Solution: Remove #[FPC] from this Ajax endpoint."
);
}
if ($has_spa) {
throw new \RuntimeException(
"#[FPC] cannot be used on #[SPA] methods\n" .
"Class: {$class_name}\n" .
"Method: {$method_name}\n" .
"File: {$file_path}\n\n" .
"SPA bootstrap methods return an empty shell that JavaScript populates.\n" .
"Caching this shell serves the same empty page for all SPA routes.\n\n" .
"Solution: Remove #[FPC] from this SPA method."
);
}
if ($has_task) {
throw new \RuntimeException(
"#[FPC] cannot be used on Task methods\n" .
"Class: {$class_name}\n" .
"Method: {$method_name}\n" .
"File: {$file_path}\n\n" .
"Solution: Remove #[FPC] from this Task method."
);
}
if (!$has_route) {
throw new \RuntimeException(
"#[FPC] requires #[Route] attribute\n" .
"Class: {$class_name}\n" .
"Method: {$method_name}\n" .
"File: {$file_path}\n\n" .
"#[FPC] can only be used on methods with a #[Route] attribute.\n\n" .
"Solution: Add #[Route('/path')] to this method, or remove #[FPC]."
);
}
}
}
}
}

398
app/RSpade/Core/SSR/Rsx_SSR.php Executable file
View File

@@ -0,0 +1,398 @@
<?php
/**
* Rsx_SSR - Server-side rendering of jqhtml components
*
* Manages the SSR Node server lifecycle and communicates via Unix socket.
* The server is started on-demand and stays running across requests. It is
* automatically restarted when the manifest build key changes (new code).
*
* All SSR operations are serialized via a Redis advisory lock to ensure
* only one PHP process manages the server lifecycle at a time.
*/
namespace App\RSpade\Core\SSR;
use App\RSpade\Core\Locks\RsxLocks;
use App\RSpade\Core\Manifest\Manifest;
class Rsx_SSR
{
/**
* Unix socket path (relative to base_path)
*/
private const SOCKET_PATH = 'storage/rsx-tmp/ssr-server.sock';
/**
* Build key sidecar file (relative to base_path)
*/
private const BUILD_KEY_FILE = 'storage/rsx-tmp/ssr-server.build_key';
/**
* PID file (relative to base_path)
*/
private const PID_FILE = 'storage/rsx-tmp/ssr-server.pid';
/**
* SSR server script (relative to base_path)
*/
private const SERVER_SCRIPT = 'bin/ssr-server.js';
/**
* Default render timeout (seconds)
*/
private const DEFAULT_TIMEOUT = 30;
/**
* Server startup timeout (milliseconds)
*/
private const STARTUP_TIMEOUT_MS = 10000;
/**
* Server startup poll interval (milliseconds)
*/
private const STARTUP_POLL_MS = 50;
/**
* RPC request ID counter
*/
private static int $request_id = 0;
/**
* Render a jqhtml component to HTML via the SSR server
*
* Acquires a Redis advisory lock, ensures the server is running with
* current code, sends the render request, and returns the result.
*
* @param string $component Component class name (e.g., 'SSR_Test_Page')
* @param array $args Component arguments (becomes this.args)
* @param string $bundle_class Bundle class name (e.g., 'SSR_Test_Bundle')
* @return array ['html' => string, 'timing' => array, 'preload' => array]
* @throws \RuntimeException on connection or render failure
*/
public static function render_component(string $component, array $args = [], string $bundle_class = ''): array
{
$lock_token = RsxLocks::get_lock(
RsxLocks::SERVER_LOCK,
RsxLocks::LOCK_SSR_RENDER,
RsxLocks::WRITE_LOCK,
self::DEFAULT_TIMEOUT + 10
);
try {
// Ensure server is running with current build key
self::_ensure_server();
// Build request
$bundle_paths = self::_get_bundle_paths($bundle_class);
$vendor_content = file_get_contents($bundle_paths['vendor_js']);
$app_content = file_get_contents($bundle_paths['app_js']);
if ($vendor_content === false) {
throw new \RuntimeException("SSR: Failed to read vendor bundle: {$bundle_paths['vendor_js']}");
}
if ($app_content === false) {
throw new \RuntimeException("SSR: Failed to read app bundle: {$bundle_paths['app_js']}");
}
$request = [
'id' => 'ssr-' . uniqid(),
'type' => 'render',
'payload' => [
'bundles' => [
['id' => 'vendor', 'content' => $vendor_content],
['id' => 'app', 'content' => $app_content],
],
'component' => $component,
'args' => empty($args) ? new \stdClass() : $args,
'options' => [
'baseUrl' => 'http://localhost',
'timeout' => self::DEFAULT_TIMEOUT * 1000,
],
],
];
$response = self::_send_request($request);
if ($response['status'] !== 'success') {
$error = $response['error'] ?? ['code' => 'UNKNOWN', 'message' => 'Unknown error'];
throw new \RuntimeException(
"SSR render failed [{$error['code']}]: {$error['message']}"
);
}
return [
'html' => $response['payload']['html'],
'timing' => $response['payload']['timing'] ?? [],
'preload' => $response['payload']['preload'] ?? [],
];
} finally {
RsxLocks::release_lock($lock_token);
}
}
/**
* Ensure the SSR server is running with current code
*
* Checks socket existence and build key. Starts or restarts as needed.
*/
private static function _ensure_server(): void
{
$socket_path = base_path(self::SOCKET_PATH);
$build_key_file = base_path(self::BUILD_KEY_FILE);
$current_build_key = Manifest::get_build_key();
if (file_exists($socket_path)) {
// Check if server has current code
$stored_build_key = file_exists($build_key_file) ? trim(file_get_contents($build_key_file)) : '';
if ($stored_build_key === $current_build_key) {
// Build key matches — verify server is responsive
if (self::_ping_server()) {
return; // Server is running and current
}
}
// Build key mismatch or server unresponsive — restart
self::_stop_server(force: true);
}
// Start fresh server
self::_start_server();
// Record the build key
file_put_contents($build_key_file, $current_build_key);
}
/**
* Start the SSR Node server
*
* Spawns a daemonized Node process listening on the Unix socket.
* The process runs independently of PHP (survives FPM worker recycling).
* Polls until the server responds to ping, or throws on timeout.
*/
private static function _start_server(): void
{
$socket_path = base_path(self::SOCKET_PATH);
$pid_file = base_path(self::PID_FILE);
$server_script = base_path(self::SERVER_SCRIPT);
if (!file_exists($server_script)) {
throw new \RuntimeException("SSR server script not found at {$server_script}");
}
// Ensure socket directory exists
$socket_dir = dirname($socket_path);
if (!is_dir($socket_dir)) {
mkdir($socket_dir, 0755, true);
}
// Clean up stale socket file
if (file_exists($socket_path)) {
@unlink($socket_path);
}
// Launch as daemon — detached from PHP process tree
$cmd = sprintf(
'cd %s && nohup node %s --socket=%s > /dev/null 2>&1 & echo $!',
escapeshellarg(base_path()),
escapeshellarg($server_script),
escapeshellarg($socket_path)
);
$pid = (int) trim(shell_exec($cmd));
if ($pid > 0) {
file_put_contents($pid_file, (string) $pid);
}
// Poll for server readiness
$iterations = self::STARTUP_TIMEOUT_MS / self::STARTUP_POLL_MS;
for ($i = 0; $i < $iterations; $i++) {
usleep(self::STARTUP_POLL_MS * 1000);
if (self::_ping_server()) {
return;
}
}
throw new \RuntimeException(
"SSR server failed to start within " . self::STARTUP_TIMEOUT_MS . "ms"
);
}
/**
* Stop the SSR server
*
* @param bool $force If true, forcefully kill after graceful attempt
*/
private static function _stop_server(bool $force = false): void
{
$socket_path = base_path(self::SOCKET_PATH);
$pid_file = base_path(self::PID_FILE);
if (file_exists($socket_path)) {
// Send shutdown command via socket
try {
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1);
if ($socket) {
stream_set_blocking($socket, true);
self::$request_id++;
$request = json_encode([
'id' => 'shutdown-' . self::$request_id,
'type' => 'shutdown',
]) . "\n";
fwrite($socket, $request);
fclose($socket);
}
} catch (\Exception $e) {
// Ignore errors during shutdown
}
if ($force) {
usleep(100000); // 100ms grace period
@unlink($socket_path);
}
}
// Kill process by PID if still running
if (file_exists($pid_file)) {
$pid = (int) trim(file_get_contents($pid_file));
if ($pid > 0 && posix_kill($pid, 0)) {
posix_kill($pid, SIGTERM);
}
@unlink($pid_file);
}
}
/**
* Ping the SSR server to check if it's alive
*
* @return bool True if server responds, false otherwise
*/
private static function _ping_server(): bool
{
$socket_path = base_path(self::SOCKET_PATH);
if (!file_exists($socket_path)) {
return false;
}
try {
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1);
if (!$socket) {
return false;
}
stream_set_blocking($socket, true);
stream_set_timeout($socket, 2);
self::$request_id++;
$request = json_encode([
'id' => 'ping-' . self::$request_id,
'type' => 'ping',
]) . "\n";
fwrite($socket, $request);
$response = fgets($socket);
fclose($socket);
if (!$response) {
return false;
}
$result = json_decode($response, true);
return isset($result['status']) && $result['status'] === 'success';
} catch (\Exception $e) {
return false;
}
}
/**
* Get compiled bundle file paths for a bundle class
*
* @param string $bundle_class Bundle class name
* @return array ['vendor_js' => string, 'app_js' => string]
* @throws \RuntimeException if bundle files not found
*/
private static function _get_bundle_paths(string $bundle_class): array
{
// Validate bundle class exists in manifest as a real bundle
if (!Manifest::php_is_subclass_of($bundle_class, \App\RSpade\Core\Bundle\Rsx_Bundle_Abstract::class)) {
throw new \RuntimeException("SSR: Invalid bundle class '{$bundle_class}' - not a registered bundle");
}
$build_dir = base_path('storage/rsx-build/bundles');
$vendor_files = glob("{$build_dir}/{$bundle_class}__vendor.*.js");
$app_files = glob("{$build_dir}/{$bundle_class}__app.*.js");
if (empty($vendor_files)) {
throw new \RuntimeException("SSR: Vendor bundle not found for {$bundle_class}. Run php artisan rsx:bundle:compile");
}
if (empty($app_files)) {
throw new \RuntimeException("SSR: App bundle not found for {$bundle_class}. Run php artisan rsx:bundle:compile");
}
return [
'vendor_js' => $vendor_files[0],
'app_js' => $app_files[0],
];
}
/**
* Send a request to the SSR server and return the response
*
* @param array $request Request data
* @return array Parsed response
* @throws \RuntimeException on connection or protocol failure
*/
private static function _send_request(array $request): array
{
$socket_path = base_path(self::SOCKET_PATH);
$timeout = self::DEFAULT_TIMEOUT + 5;
$socket = @stream_socket_client(
'unix://' . $socket_path,
$errno,
$errstr,
5
);
if (!$socket) {
throw new \RuntimeException(
"SSR: Cannot connect to SSR server at {$socket_path} - {$errstr}"
);
}
stream_set_blocking($socket, true);
stream_set_timeout($socket, $timeout);
$request_json = json_encode($request) . "\n";
fwrite($socket, $request_json);
$response_line = fgets($socket);
$meta = stream_get_meta_data($socket);
fclose($socket);
if ($response_line === false) {
if (!empty($meta['timed_out'])) {
throw new \RuntimeException("SSR: Request timed out after {$timeout}s");
}
throw new \RuntimeException("SSR: Empty response from server");
}
$response = json_decode(trim($response_line), true);
if ($response === null) {
throw new \RuntimeException("SSR: Invalid JSON response from server");
}
return $response;
}
}

View File

@@ -616,7 +616,17 @@ class Session extends Rsx_System_Model_Abstract
return;
}
self::__activate();
self::init();
// If no session exists (anonymous visitor), store as request-scoped
// override without creating a session. The site_id will be available
// via get_site_id() for the duration of this request.
if (empty(self::$_session)) {
self::$_request_site_id_override = $site_id;
self::$_site = null;
return;
}
// Skip if already set
if (self::get_site_id() === $site_id) {

View File

@@ -33,6 +33,19 @@ class Jqhtml_Integration {
* This runs during framework_modules_define, before any DOM processing.
*/
static _on_framework_modules_define() {
// ─────────────────────────────────────────────────────────────────────
// SSR Preload Data Injection
//
// If the page was SSR-rendered, window.rsxapp.ssr_preload contains
// captured component data from the SSR server. Seed jqhtml's preload
// cache so on_load() is skipped for components with matching data.
// Must happen before component registration / DOM hydration.
// ─────────────────────────────────────────────────────────────────────
if (window.rsxapp && window.rsxapp.page_data && window.rsxapp.page_data.ssr_preload && typeof jqhtml.set_preload_data === 'function') {
jqhtml.set_preload_data(window.rsxapp.page_data.ssr_preload);
console_debug('JQHTML_INIT', 'SSR preload data seeded: ' + window.rsxapp.page_data.ssr_preload.length + ' entries');
}
// ─────────────────────────────────────────────────────────────────────
// Register Component Classes with jqhtml Runtime
//
@@ -135,6 +148,12 @@ class Jqhtml_Integration {
*/
static async _on_framework_modules_init() {
await jqhtml.boot();
// Clear any remaining SSR preload data after hydration completes
if (typeof jqhtml.clear_preload_data === 'function') {
jqhtml.clear_preload_data();
}
Rsx.trigger('jqhtml_ready');
}
}

View File

@@ -86,6 +86,13 @@ class Flash_Alert
*/
public static function get_pending_messages(): array
{
// No session cookie = no session = no pending messages.
// Avoid calling get_session_id() which would create a session
// for anonymous visitors who have never triggered session activation.
if (empty($_COOKIE['rsx'])) {
return [];
}
$session_id = Session::get_session_id();
if ($session_id === null) {
return [];

258
app/RSpade/man/fpc.txt Executable file
View File

@@ -0,0 +1,258 @@
FPC(3) RSX Framework Manual FPC(3)
NAME
FPC - Full Page Cache via Node.js reverse proxy with Redis
SYNOPSIS
Controller attribute:
#[FPC]
#[Route('/about', methods: ['GET'])]
public static function index(Request $request, array $params = [])
PHP cache management:
use App\RSpade\Core\FPC\Rsx_FPC;
Rsx_FPC::clear(); // Clear all FPC entries
Rsx_FPC::clear_url('/about'); // Clear specific URL
DESCRIPTION
The Full Page Cache (FPC) is a Node.js reverse proxy that sits between
nginx and PHP. Pages marked with #[FPC] are cached in Redis after first
render. Subsequent requests for the same URL are served directly from
Redis without touching PHP, with ETag validation for 304 responses.
Unlike traditional PHP caching (which still boots Laravel on every
request), the FPC proxy is a long-running Node.js process that serves
cached responses in under 1ms. PHP is only invoked on cache misses.
FPC is designed for public, anonymous pages like marketing sites,
documentation, and SSR-rendered content. Pages accessed by logged-in
users always bypass the cache.
Architecture:
browser -> nginx ->
/_ajax/ -> ajax FPM pool (unchanged)
static files -> try_files (unchanged)
everything else -> FPC proxy (port 3200) ->
cache HIT -> Redis -> 304 or cached HTML
cache MISS -> nginx backend (port 3201) -> FPM ->
if X-RSpade-FPC header -> cache in Redis
ATTRIBUTE USAGE
Add #[FPC] to any controller method that should be cached:
#[FPC]
#[Route('/pricing', methods: ['GET'])]
public static function pricing(Request $request, array $params = [])
{
return rsx_view('Pricing_Page');
}
FPC Constraints:
- Requires #[Route] attribute (enforced by manifest validation)
- Cannot combine with #[Ajax_Endpoint]
- Cannot combine with #[SPA]
- Cannot combine with #[Task]
The #[FPC] attribute only affects GET requests from anonymous visitors.
For logged-in users, POST requests, or non-GET methods, the request
passes through to PHP normally with no caching.
CACHE BEHAVIOR
Cache Key:
fpc:{build_key}:{sha1(path?sorted_query_params)}
The build key is the manifest hash, written to
system/storage/rsx-build/build_key during manifest save. When any file
changes trigger a manifest rebuild, the build key changes and all FPC
entries are effectively invalidated (new cache keys, old entries expire
via Redis LRU).
Cache Entry (stored as JSON in Redis):
url - Request path
html - Full HTML response body
status_code - HTTP status (200, 301, 302, etc.)
content_type - Response Content-Type header
location - Redirect target (for 3xx responses)
etag - SHA1 hash of body (first 30 chars)
cached_at - ISO timestamp of cache time
ETag Validation:
Client sends If-None-Match header with cached ETag.
If ETag matches cached entry, proxy returns 304 Not Modified
with no body. This saves bandwidth on repeat visits.
Session Cookie Bypass:
If the request contains an 'rsx' cookie (indicating the visitor
has an active session), the proxy passes through directly to PHP.
Session users never receive cached content.
POST/Non-GET Bypass:
Non-GET/HEAD methods always pass through to PHP.
HEAD requests can READ from cache but never POPULATE it.
RESPONSE HEADERS
X-FPC-Cache: HIT - Response served from Redis cache
X-FPC-Cache: MISS - Response served from PHP (and cached for next time)
ETag: {hash} - Content hash for 304 validation
The internal X-RSpade-FPC header (set by PHP to signal cacheability)
is stripped by the proxy and never sent to clients.
PHP CACHE MANAGEMENT
Rsx_FPC::clear()
Clear all FPC entries for the current build key.
Uses SCAN (not KEYS) to avoid blocking Redis.
Returns count of deleted entries.
Rsx_FPC::clear_url(string $url)
Clear FPC entry for a specific URL path.
Handles query parameter sorting to match proxy key generation.
Returns true if entry was deleted.
Examples:
// Clear all cached pages after content update
Rsx_FPC::clear();
// Clear specific page after editing its content
Rsx_FPC::clear_url('/about');
Rsx_FPC::clear_url('/search?q=test&page=1');
CONFIGURATION
Environment variables (in .env):
FPC_PROXY_PORT Port for FPC proxy (default: 3200)
FPC_BACKEND_PORT Port for nginx backend (default: 3201)
REDIS_HOST Redis server host (default: 127.0.0.1)
REDIS_PORT Redis server port (default: 6379)
REDIS_PASSWORD Redis password (default: none)
Redis uses DB 0 (default database with LRU eviction policy).
NGINX SETUP
The FPC requires two nginx changes:
1. Backend server block (accessed by proxy for cache misses):
server {
listen 127.0.0.1:3201;
root /var/www/html/system/public;
location /_ajax/ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_pass unix:/run/php/php-fpm-ajax.sock;
}
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php-fpm.sock;
}
}
2. Main server blocks - replace location / with proxy fallback:
location / {
try_files $uri $uri/ @fpc_proxy;
}
location @fpc_proxy {
proxy_pass http://127.0.0.1:3200;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
SUPERVISOR
The FPC proxy runs as a supervised service:
/system/supervisor/fpc-proxy.conf
Start manually for testing:
node system/bin/fpc-proxy.js
DISPATCHER BEHAVIOR
When the Dispatcher matches a route with #[FPC]:
1. Checks request is GET with no POST/FILES data
2. Calls Session::init() to load existing session (does NOT create one)
3. If user is logged in, skips FPC (no header set)
4. If anonymous, blanks all cookies except 'rsx' from $_COOKIE
5. Executes controller method normally
6. Adds X-RSpade-FPC: 1 header to response
7. Removes Set-Cookie headers from response
Cookie blanking (step 4) ensures controller code produces identical
output for all anonymous visitors regardless of any tracking cookies.
SESSION COOKIE SAFETY
RSpade does NOT create session cookies for anonymous visitors.
Session::init() only reads existing cookies, never creates new ones.
Session::set_site_id() stores site_id as a request-scoped override
for anonymous visitors without creating a database session.
Sessions are only created when code explicitly calls:
- Session::get_session_id()
- Session::get_session()
- Session::set_login_user_id()
This ensures FPC-cacheable pages never produce Set-Cookie headers
that would prevent caching.
DEBUGGING
Check FPC cache status:
curl -sI http://localhost:3200/your-page | grep X-FPC
View cached entry:
redis-cli GET "fpc:{build_key}:{sha1_of_url}"
List all FPC keys:
redis-cli KEYS "fpc:*"
Clear all FPC entries:
redis-cli KEYS "fpc:*" | xargs -r redis-cli DEL
Check proxy logs:
/var/log/supervisor/fpc-proxy.log
/var/log/supervisor/fpc-proxy-error.log
TESTING
Integration tests in /system/app/RSpade/tests/fpc/:
01_session_cookie_behavior - Verifies no cookies for anonymous
02_fpc_cache_behavior - Cache MISS/HIT, ETag 304, bypasses
Run tests:
./system/app/RSpade/tests/fpc/01_session_cookie_behavior/run_test.sh
./system/app/RSpade/tests/fpc/02_fpc_cache_behavior/run_test.sh
FILES
system/bin/fpc-proxy.js Node.js proxy server
system/app/RSpade/Core/FPC/Rsx_FPC.php PHP cache utility
system/supervisor/fpc-proxy.conf Supervisor config
system/storage/rsx-build/build_key Build key file
system/app/RSpade/tests/fpc/ Integration tests
SEE ALSO
ssr(3) - Server-side rendering (produces HTML that FPC caches)
session(3) - Session management and cookie behavior

204
app/RSpade/man/ssr.txt Executable file
View File

@@ -0,0 +1,204 @@
SSR(3) RSX Framework Manual SSR(3)
NAME
SSR - Server-side rendering of jqhtml components via Node.js
SYNOPSIS
use App\RSpade\Core\SSR\Rsx_SSR;
$result = Rsx_SSR::render_component('Component_Name', $args, 'Bundle_Class');
// $result = ['html' => '...', 'timing' => [...], 'preload' => [...]]
DESCRIPTION
Rsx_SSR renders jqhtml components to HTML strings on the server using a
long-running Node.js process. The rendered HTML is injected into a Blade
template so the browser receives fully-formed markup without waiting for
JavaScript execution.
Primary use case: search engine crawlers and social media link previews.
Crawlers that don't execute JavaScript see empty SPA shells. SSR provides
real HTML content for indexing while the same components work as normal
client-side jqhtml components for regular users.
SSR pages are for unauthenticated users only. The SSR Node environment
has no session, no cookies, and no user context. Authenticated users
should be served the normal SPA/CSR version of the page.
HOW IT WORKS
1. PHP controller calls Rsx_SSR::render_component()
2. PHP acquires a Redis advisory lock (LOCK_SSR_RENDER) to serialize
SSR operations across concurrent requests
3. PHP ensures the SSR Node server is running (auto-starts if needed)
4. PHP reads the compiled bundle files and sends them + the component
name to the Node server over a Unix socket
5. Node creates an isolated jsdom environment, executes the bundles,
runs the full RSpade 10-phase boot lifecycle (with SSR guards on
browser-only classes), then renders the component
6. The component's on_load() runs normally, making Ajax calls back to
PHP to fetch data (e.g., Controller.get_page_data())
7. Node returns the rendered HTML and any preload data
8. PHP injects the HTML into a Blade template and optionally seeds
preload data via PageData for client-side hydration
The Node server stays running across requests and is automatically
restarted when the manifest build key changes (indicating code changes).
CONTROLLER USAGE
A typical SSR controller has three parts: the SSR route, a CSR fallback
route, and a public Ajax endpoint for data.
1. SSR Route (returns pre-rendered HTML):
use App\RSpade\Core\SSR\Rsx_SSR;
use App\RSpade\Core\View\PageData;
#[Route('/pricing', methods: ['GET'])]
public static function index(Request $request, array $params = [])
{
$result = Rsx_SSR::render_component(
'Pricing_Page', // Component class name
[], // Component args (becomes this.args)
'Marketing_Bundle' // Bundle containing the component
);
// Seed preload data so client-side hydration skips on_load()
if (!empty($result['preload'])) {
PageData::add([
'ssr' => true,
'ssr_preload' => $result['preload'],
]);
}
return rsx_view('Pricing_Index', [
'html' => $result['html'],
]);
}
2. Blade Template (injects SSR HTML into the page):
The Blade template outputs {!! $html !!} inside the container where
the component would normally render client-side.
3. Public Ajax Endpoint (serves data to both SSR and CSR):
#[Ajax_Endpoint]
public static function get_page_data(Request $request, array $params = [])
{
return [
'plans' => Plan_Model::where('active', true)->get()->toArray(),
'features' => Feature_Model::all()->toArray(),
];
}
The component's on_load() calls this endpoint identically whether
running server-side in Node or client-side in the browser:
async on_load() {
const data = await Marketing_Controller.get_page_data();
this.data.plans = data.plans;
this.data.features = data.features;
}
COMPONENT RESTRICTIONS
Components rendered via SSR must follow these rules:
- Data loading goes in on_load(), not on_create()
on_create() should only set empty defaults (this.data.items = []).
on_load() fetches data via Ajax endpoints. This is required because
SSR captures the data returned by on_load() for the preload cache.
- Ajax endpoints must be public (@auth-exempt on the controller)
The SSR Node server has no session. Ajax calls from SSR go through
PHP as unauthenticated requests.
- No browser-only APIs in on_create() or on_load()
window.innerWidth, navigator, localStorage (the real ones), etc.
are not available. on_ready() is fine since it only runs client-side.
- No user-specific content
SSR pages are cached and served to all unauthenticated users.
User-specific content belongs in authenticated SPA routes.
SERVER LIFECYCLE
The SSR Node server is managed automatically by Rsx_SSR:
- Auto-start: First render_component() call starts the server
- Persistence: Server stays running across PHP requests
- Auto-restart: Server restarts when manifest build key changes
- Concurrency: Redis advisory lock serializes SSR operations
- Cleanup: Server is killed by PID when restarting
Files:
storage/rsx-tmp/ssr-server.sock Unix socket
storage/rsx-tmp/ssr-server.pid Process ID
storage/rsx-tmp/ssr-server.build_key Last known build key
The server process is daemonized (detached from PHP). It survives
PHP-FPM worker recycling and terminal disconnects.
NGINX AND PHP-FPM DEADLOCK PREVENTION
SSR components that call Ajax endpoints create a callback loop:
Browser -> nginx -> PHP-FPM (page request)
|
v
Node SSR server
|
v (Ajax callback)
nginx -> PHP-FPM (ajax request)
If both the page request and the Ajax callback compete for the same
PHP-FPM worker pool, a deadlock occurs when all workers are busy
handling SSR page requests, leaving no workers for Ajax callbacks.
Solution: Route Ajax requests to a separate PHP-FPM pool.
PHP-FPM Configuration:
/etc/php/8.4/fpm/pool.d/www.conf (page requests):
[www]
listen = /run/php/php-fpm.sock
pm = ondemand
pm.max_children = 4
/etc/php/8.4/fpm/pool.d/ajax.conf (Ajax requests):
[ajax]
listen = /run/php/php-fpm-ajax.sock
pm = ondemand
pm.max_children = 4
Nginx Configuration (add to each server block):
# Ajax requests use separate FPM pool (prevents SSR deadlock)
location /_ajax/ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_pass unix:/run/php/php-fpm-ajax.sock;
}
For HTTPS server blocks, also add:
fastcgi_param HTTPS 'on';
This ensures SSR Ajax callbacks always have available workers,
regardless of how many page requests are in-flight.
PERFORMANCE
First request after server start: ~800-1000ms (server startup + render)
Subsequent requests (server warm): ~150-300ms (render only)
Bundle code is cached in-memory by the Node server across requests.
For high-traffic SSR pages, combine with the Full Page Cache system
(see rsx:man ssr_fpc) to serve pre-rendered HTML from Redis without
invoking the SSR server at all.
SEE ALSO
rsx:man ssr_fpc Full Page Cache for static SSR pages
rsx:man pagedata Server-to-client data passing
rsx:man jqhtml Component system
rsx:man ajax_error_handling Ajax endpoint patterns
AUTHOR
RSpade Framework
RSpade March 2026 SSR(3)

View File

@@ -1,359 +0,0 @@
================================================================================
SSR FULL PAGE CACHE (FPC)
================================================================================
OVERVIEW
The SSR (Server-Side Rendered) Full Page Cache system pre-renders static pages
via headless Chrome (Playwright) and caches the resulting HTML in Redis for
optimal SEO and performance. Routes marked with #[Static_Page] attribute are
automatically cached and served to unauthenticated users.
This system is ideal for:
- Public-facing pages (landing pages, about, blog posts, documentation)
- Content that changes infrequently but is accessed frequently
- Pages requiring optimal SEO (search engines get pre-rendered HTML)
- Reducing server load for high-traffic public pages
CORE CONCEPTS
1. STATIC PAGE MARKING
Routes opt-in to FPC by adding the #[Static_Page] attribute to their
controller method. Only routes with this attribute are eligible for caching.
2. AUTHENTICATION-AWARE CACHING
FPC only serves cached content to unauthenticated users (no active session).
Logged-in users always get dynamic content, ensuring personalized experiences.
3. BUILD-KEY AUTO-INVALIDATION
Cache keys incorporate the application's build_key, ensuring all cached
pages are automatically invalidated on deployment. No manual cache clearing
needed during deployments.
4. ETAG VALIDATION
Each cached page includes a 30-character ETag for HTTP cache validation.
Browsers can send If-None-Match headers to receive 304 Not Modified
responses, reducing bandwidth usage.
5. REDIRECT CACHING
If a route redirects (300-399 status codes), the FPC system caches the
first redirect response without following it, preserving redirect behavior.
6. QUERY PARAMETER STRIPPING
GET parameters are stripped from URLs before cache key generation, ensuring
/about and /about?utm_source=email serve the same cached content.
ATTRIBUTE SYNTAX
Add the #[Static_Page] attribute to any route method:
#[Static_Page]
#[Route('/about')]
public static function about(Request $request, array $params = [])
{
return view('frontend.about');
}
Requirements:
- Route must be GET only (POST, PUT, DELETE not supported)
- Route must not require authentication (served to unauthenticated users only)
- Route should produce consistent output regardless of GET parameters
CACHE LIFECYCLE
1. ROUTE DISCOVERY
During request dispatch, the Dispatcher checks if the matched route has
the #[Static_Page] attribute.
2. SESSION CHECK
If the user has an active session, FPC is bypassed and the route executes
normally to provide personalized content.
3. CACHE LOOKUP
For unauthenticated users, the Dispatcher queries Redis for a cached version
using the key format: ssr_fpc:{build_key}:{sha1(url)}
4. CACHE GENERATION (ON MISS)
If no cache exists, the system automatically runs:
php artisan rsx:ssr_fpc:create /route
This command:
- Launches headless Chrome via Playwright
- Sets X-RSpade-FPC-Client header to prevent infinite loops
- Navigates to the route and waits for _debug_ready event
- Captures the fully rendered DOM or redirect response
- Stores the result in Redis with metadata (ETag, build_key, etc.)
5. CACHE SERVING
The cached HTML is served with appropriate headers:
- ETag: {30-char hash} for cache validation
- Cache-Control: public, max-age=300 (5 min in production)
- Cache-Control: no-cache, must-revalidate (0s in development)
6. CACHE INVALIDATION
Caches are automatically invalidated when:
- Application is deployed (build_key changes)
- Redis LRU eviction policy removes old entries
- Manual reset via rsx:ssr_fpc:reset command
COMMANDS
php artisan rsx:ssr_fpc:create <url>
Generate static cache for a specific URL.
Arguments:
url The URL path to cache (e.g., /about)
Behavior:
- Only available in non-production environments
- Requires rsx.ssr_fpc.enabled = true
- Strips GET parameters from URL
- Launches Playwright to render page
- Stores result in Redis
- Runs under GENERATE_STATIC_CACHE exclusive lock
Examples:
php artisan rsx:ssr_fpc:create /
php artisan rsx:ssr_fpc:create /about
php artisan rsx:ssr_fpc:create /blog/post-title
php artisan rsx:ssr_fpc:reset
Clear all SSR FPC cache entries from Redis.
Behavior:
- Available in all environments (local, staging, production)
- Safe to run - only affects ssr_fpc:* keys
- Does NOT affect application caches, sessions, or queue jobs
- Reports count of cleared cache entries
When to use:
- After major content updates across the site
- When troubleshooting caching issues
- Before deployment to ensure fresh cache generation (optional)
- When build_key changes (auto-invalidation should handle this)
CONFIGURATION
Configuration is stored in config/rsx.php under the 'ssr_fpc' key:
'ssr_fpc' => [
// Enable SSR Full Page Cache system
'enabled' => env('SSR_FPC_ENABLED', false),
// Playwright generation timeout in milliseconds
'generation_timeout' => env('SSR_FPC_TIMEOUT', 30000),
],
Environment Variables:
SSR_FPC_ENABLED Enable/disable the FPC system (default: false)
SSR_FPC_TIMEOUT Page generation timeout in ms (default: 30000)
To enable FPC, add to .env:
SSR_FPC_ENABLED=true
REDIS CACHE STRUCTURE
Cache Key Format:
ssr_fpc:{build_key}:{sha1(url)}
Example:
ssr_fpc:abc123def456:5d41402abc4b2a76b9719d911017c592
Cache Value (JSON):
{
"url": "/about",
"code": 200,
"page_dom": "<!DOCTYPE html>...",
"redirect": null,
"build_key": "abc123def456",
"etag": "abc123def456789012345678901234",
"generated_at": "2025-10-17 14:32:10"
}
For redirect responses:
{
"url": "/old-page",
"code": 301,
"page_dom": null,
"redirect": "/new-page",
"build_key": "abc123def456",
"etag": "abc123def456789012345678901234",
"generated_at": "2025-10-17 14:32:10"
}
Cache Eviction:
Redis LRU (Least Recently Used) policy automatically removes old entries
when memory limits are reached. No manual TTL management required.
SECURITY CONSIDERATIONS
1. INFINITE LOOP PREVENTION
The Playwright script sets X-RSpade-FPC-Client: 1 header to identify itself
as a cache generation request. Session::__is_fpc_client() checks this header
to prevent FPC from serving cached content to the generation process.
2. PRODUCTION COMMAND BLOCKING
The rsx:ssr_fpc:create command throws a fatal error in production to prevent
accidental cache generation in production environments. Cache generation
should happen automatically on first request or via staging/local environments.
3. AUTHENTICATION BYPASS
FPC only serves to unauthenticated users. Auth checks still run for logged-in
users, ensuring secure routes remain protected even if accidentally marked
with #[Static_Page].
4. QUERY PARAMETER STRIPPING
GET parameters are stripped before caching to prevent cache poisoning via
malicious query strings. Routes that depend on GET parameters should NOT
use #[Static_Page].
PERFORMANCE CHARACTERISTICS
Cache Hit (Unauthenticated User):
- Served directly from Redis before route execution
- No PHP controller execution
- No database queries
- Minimal memory usage
- Response time: ~1-5ms
Cache Miss (First Request):
- Playwright launches headless Chrome
- Page renders fully (waits for _debug_ready + network idle)
- DOM captured and stored in Redis
- Response time: ~500-2000ms (one-time cost)
Authenticated User:
- FPC bypassed completely
- Normal route execution
- No performance impact
DEBUGGING AND TROUBLESHOOTING
Enable console_debug output for FPC:
CONSOLE_DEBUG_FILTER=SSR_FPC php artisan rsx:debug /about
Common issues:
1. Cache not being served
- Check: rsx.ssr_fpc.enabled = true
- Check: Route has #[Static_Page] attribute
- Check: User is unauthenticated (no active session)
- Check: Redis is running and accessible
2. Cache generation fails
- Check: Playwright is installed (npm install)
- Check: Timeout setting (increase SSR_FPC_TIMEOUT)
- Check: Route returns valid HTML (not JSON/PDF/etc.)
- Check: Route doesn't redirect to external domains
3. Stale cache after deployment
- Cache should auto-invalidate (build_key changed)
- Manual reset: php artisan rsx:ssr_fpc:reset
- Check: build_key is being regenerated on deployment
4. 304 responses not working
- Check: Browser sends If-None-Match header
- Check: ETag matches exactly (case-sensitive)
- Check: Response includes ETag header
FUTURE ROADMAP
Planned enhancements:
1. SITEMAP INTEGRATION
Automatically generate static caches for all routes in sitemap.xml during
deployment. Ensures all public pages are pre-cached before first user visit.
2. PARALLEL GENERATION
Generate multiple static caches concurrently using a worker pool, reducing
total cache generation time for large sites.
3. SHARED SECRET KEYS
Support deployment across multiple servers with shared cache keys, enabling
cache pre-generation on staging before production deployment.
4. EXTERNAL CACHE SERVICE
Move cache generation to a separate service (e.g., AWS Lambda, dedicated
worker) to reduce load on application servers during cache regeneration.
5. PROGRAMMATIC CACHE RESET
Add programmatic methods to clear specific route caches after content updates:
Rsx_Static_Cache::clear('/blog/post-slug')
Rsx_Static_Cache::clear_pattern('/blog/*')
RELATED DOCUMENTATION
- php artisan rsx:man routing (Route system and attributes)
- php artisan rsx:man caching (General caching concepts)
- php artisan rsx:man console_debug (Debug output configuration)
- php artisan rsx:man auth (Authentication system)
EXAMPLES
Basic static page:
#[Static_Page]
#[Route('/')]
#[Auth('Permission::anybody()')]
public static function index(Request $request, array $params = [])
{
return view('frontend.index', [
'featured_products' => Product_Model::where('featured', true)->get(),
]);
}
Static page with redirect:
#[Static_Page]
#[Route('/old-page')]
#[Auth('Permission::anybody()')]
public static function old_page(Request $request, array $params = [])
{
return redirect('/new-page', 301);
}
NOT suitable for FPC (query-dependent):
// ❌ DO NOT use #[Static_Page] - depends on GET parameters
#[Route('/search')]
#[Auth('Permission::anybody()')]
public static function search(Request $request, array $params = [])
{
$query = $params['q'] ?? '';
return view('search.results', [
'results' => Product_Model::search($query)->get(),
]);
}
Pre-generate cache in deployment script:
#!/bin/bash
# deployment.sh
# Deploy application
git pull
composer install --no-dev --optimize-autoloader
php artisan migrate
# Pre-generate static caches for high-traffic pages
php artisan rsx:ssr_fpc:create /
php artisan rsx:ssr_fpc:create /about
php artisan rsx:ssr_fpc:create /pricing
php artisan rsx:ssr_fpc:create /contact
# Note: This is optional - caches will auto-generate on first request
================================================================================