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) {