Framework updates
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
105
app/RSpade/Core/FPC/Rsx_FPC.php
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
class Rsx_Droppable {
|
||||
static _on_framework_core_init() {
|
||||
if (Rsx.is_ssr()) return;
|
||||
Rsx_Droppable._init();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
398
app/RSpade/Core/SSR/Rsx_SSR.php
Executable 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
258
app/RSpade/man/fpc.txt
Executable 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
204
app/RSpade/man/ssr.txt
Executable 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
================================================================================
|
||||
Reference in New Issue
Block a user