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

@@ -1,93 +0,0 @@
# SSR Full Page Cache (FPC) Implementation Plan
## Overview
Server-side rendered static page caching system using Playwright + Redis. Routes marked with `#[Static_Page]` auto-generate and serve pre-rendered HTML to unauthenticated users.
## Implementation Order
### Phase 1: Foundation & Research
1. Study `rsx:debug` command and Playwright script structure
2. Understand current Dispatch.php flow and route attribute processing
### Phase 2: Core Commands
3. Create `rsx:ssr_fpc:create` command (copy from `rsx:debug`)
4. Create Playwright script `generate-static-cache.js` in command's resource directory
5. Implement exclusive lock `GENERATE_STATIC_CACHE` in Playwright script
6. Strip GET parameters from URL before rendering
7. Handle redirect interception (manual redirect mode)
8. Capture response: DOM + headers OR redirect location
9. Generate cache structure with 30-char ETag (SHA1 of build_key + URL + content)
### Phase 3: Storage Layer
10. Implement Redis cache with key format: `ssr_fpc:{build_key}:{sha1(url)}`
11. Store JSON: `{url, code, page_dom/redirect, build_key, etag, generated_at}`
12. Add comprehensive error logging to `storage/logs/ssr-fpc-errors.log`
### Phase 4: Runtime Integration
13. Update `Session::__is_fpc_client()` with header check: `X-RSpade-FPC-Client: 1`
14. Add header to Playwright script
15. Create `#[Static_Page]` attribute stub in `.vscode/attribute-stubs.php`
16. Modify Dispatch.php to:
- Check for `#[Static_Page]` attribute
- Verify `!Session::is_active()` (unauthenticated only)
- Skip FPC if `Session::__is_fpc_client()` is true
- Check Redis cache, generate if missing (fatal on failure)
- Validate ETag for 304 responses
- Serve with proper cache headers (0s dev, 5min prod)
### Phase 5: Management & Config
17. Create `rsx:ssr_fpc:reset` command (flush Redis `ssr_fpc:*` keys)
18. Add config to `config/rsx.php`:
```php
'ssr_fpc' => [
'enabled' => env('SSR_FPC_ENABLED', false),
'generation_timeout' => 30000, // ms
]
```
### Phase 6: Documentation
19. Create `app/RSpade/man/ssr_fpc.txt` with:
- Purpose and usage
- Attribute syntax
- Cache lifecycle
- Bypass headers
- Future roadmap (sitemap, parallelization, external service, shared secret)
- Security considerations
### Phase 7: Testing
20. Test simple static page generation and serving
21. Test redirect caching and serving
22. Test ETag validation (304 responses)
23. Test authenticated user bypass
24. Test cache invalidation on build_key change
## Key Technical Decisions
### Redis Cache Key
Format: `ssr_fpc:{build_key}:{sha1(request_path)}`
- Auto-invalidates on deployment (build_key changes)
- URL hash prevents key collisions
- GET params stripped before hashing
### ETag
First 30 chars of SHA1(build_key + url + content)
### FPC Client Header
`X-RSpade-FPC-Client: 1`
### Session Check
`Session::is_active()` (not `RsxAuth::check()`)
### Redirect Handling
Cache first 302, don't follow
### Cache TTL
Indefinite (Redis LRU handles eviction)
## Future Roadmap (Not in Initial Implementation)
- Option for `rsx:ssr_fpc:create --from-sitemap` rather than a specific url
- Shared, private, automatic key for the fpc runner, so the fpc headers are only recognized by the server itself
- The same should be done for `rsx:debug`
- External service to generate the fpc renderings
- Parallelization
- Programmatic cache reset for things like updating cms or blog posts

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
================================================================================

360
bin/fpc-proxy.js Executable file
View File

@@ -0,0 +1,360 @@
#!/usr/bin/env node
/**
* RSpade Full Page Cache (FPC) Proxy
*
* Reverse proxy that caches HTML responses marked with X-RSpade-FPC header.
* Sits between nginx and the PHP backend. Serves cached responses from Redis
* with ETag/304 validation so cached pages never hit PHP.
*
* Usage: node system/bin/fpc-proxy.js
*
* Requires .env: REDIS_HOST, REDIS_PORT, REDIS_PASSWORD
* Optional: FPC_PROXY_PORT (default 3200), FPC_BACKEND_PORT (default 3201)
*/
const http = require('http');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
// Load .env from project root
const env_path = path.resolve(__dirname, '../../.env');
if (fs.existsSync(env_path)) {
const env_content = fs.readFileSync(env_path, 'utf8');
for (const line of env_content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.substring(0, eq);
let value = trimmed.substring(eq + 1);
// Strip surrounding quotes
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (!process.env[key]) {
process.env[key] = value;
}
}
}
const FPC_PORT = parseInt(process.env.FPC_PROXY_PORT || '3200', 10);
const BACKEND_PORT = parseInt(process.env.FPC_BACKEND_PORT || '3201', 10);
const BACKEND_HOST = '127.0.0.1';
const REDIS_HOST = process.env.REDIS_HOST || '127.0.0.1';
const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10);
const REDIS_PASSWORD = process.env.REDIS_PASSWORD === 'null' ? undefined : process.env.REDIS_PASSWORD;
const BUILD_KEY_PATH = path.resolve(__dirname, '../storage/rsx-build/build_key');
const SESSION_COOKIE_NAME = 'rsx';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let redis_client = null;
let redis_available = false;
let build_key = 'none';
// ---------------------------------------------------------------------------
// Build key management
// ---------------------------------------------------------------------------
function load_build_key() {
try {
const content = fs.readFileSync(BUILD_KEY_PATH, 'utf8').trim();
if (content) {
const old_key = build_key;
build_key = content;
if (old_key !== 'none' && old_key !== build_key) {
console.log(`[fpc] Build key changed: ${old_key.substring(0, 8)}... -> ${build_key.substring(0, 8)}...`);
}
}
} catch (err) {
// File doesn't exist yet — use fallback
}
}
function watch_build_key() {
const dir = path.dirname(BUILD_KEY_PATH);
try {
// Watch the directory for changes to the build_key file
fs.watch(dir, (event_type, filename) => {
if (filename === 'build_key') {
// Small delay to ensure file write is complete
setTimeout(() => load_build_key(), 50);
}
});
} catch (err) {
console.error('[fpc] Cannot watch build key directory:', err.message);
// Fall back to polling
setInterval(() => load_build_key(), 5000);
}
}
// ---------------------------------------------------------------------------
// Cache key generation
// ---------------------------------------------------------------------------
function make_cache_key(url) {
// Parse URL to extract path and sorted query params
const parsed = new URL(url, 'http://localhost');
const path_str = parsed.pathname;
// Sort query parameters for consistent cache keys
const params = new URLSearchParams(parsed.searchParams);
const sorted = new URLSearchParams([...params.entries()].sort());
const full_url = sorted.toString()
? path_str + '?' + sorted.toString()
: path_str;
const hash = crypto.createHash('sha1').update(full_url).digest('hex');
return `fpc:${build_key}:${hash}`;
}
// ---------------------------------------------------------------------------
// Cookie parsing
// ---------------------------------------------------------------------------
function has_session_cookie(req) {
const cookie_header = req.headers.cookie;
if (!cookie_header) return false;
// Parse cookies to check for the session cookie
const cookies = cookie_header.split(';');
for (const cookie of cookies) {
const [name] = cookie.trim().split('=');
if (name === SESSION_COOKIE_NAME) {
return true;
}
}
return false;
}
// ---------------------------------------------------------------------------
// Proxy logic
// ---------------------------------------------------------------------------
function proxy_to_backend(client_req, client_res, cache_key) {
const options = {
hostname: BACKEND_HOST,
port: BACKEND_PORT,
path: client_req.url,
method: client_req.method,
headers: { ...client_req.headers },
};
const proxy_req = http.request(options, (proxy_res) => {
const fpc_header = proxy_res.headers['x-rspade-fpc'];
// No FPC header or no cache_key — pass through as-is
if (!fpc_header || !cache_key) {
// Strip internal header before sending to client
const headers = { ...proxy_res.headers };
delete headers['x-rspade-fpc'];
client_res.writeHead(proxy_res.statusCode, headers);
proxy_res.pipe(client_res);
return;
}
// FPC-marked response — collect body and cache it
const chunks = [];
proxy_res.on('data', (chunk) => chunks.push(chunk));
proxy_res.on('end', () => {
const body = Buffer.concat(chunks);
const body_str = body.toString('utf8');
const etag = crypto.createHash('sha1')
.update(body_str)
.digest('hex')
.substring(0, 30);
// Build cache entry
const is_redirect = proxy_res.statusCode >= 300 && proxy_res.statusCode < 400;
const entry = {
url: new URL(client_req.url, 'http://localhost').pathname,
status_code: proxy_res.statusCode,
content_type: proxy_res.headers['content-type'] || 'text/html; charset=UTF-8',
etag: etag,
cached_at: new Date().toISOString(),
};
if (is_redirect) {
entry.location = proxy_res.headers['location'] || '';
entry.html = '';
} else {
entry.html = body_str;
}
// Store in Redis (fire-and-forget)
if (redis_available) {
redis_client.set(cache_key, JSON.stringify(entry)).catch((err) => {
console.error('[fpc] Redis write error:', err.message);
});
}
// Build response headers — strip internals, add cache headers
const response_headers = { ...proxy_res.headers };
delete response_headers['x-rspade-fpc'];
delete response_headers['set-cookie'];
response_headers['etag'] = etag;
response_headers['x-fpc-cache'] = 'MISS';
client_res.writeHead(proxy_res.statusCode, response_headers);
client_res.end(body);
});
});
proxy_req.on('error', (err) => {
console.error('[fpc] Backend error:', err.message);
if (!client_res.headersSent) {
client_res.writeHead(502, { 'Content-Type': 'text/plain' });
client_res.end('Bad Gateway');
}
});
// Pipe request body to backend (for POST, etc.)
client_req.pipe(proxy_req);
}
// ---------------------------------------------------------------------------
// HTTP server
// ---------------------------------------------------------------------------
const server = http.createServer(async (req, res) => {
// 1. Non-GET requests: proxy straight through, no caching
if (req.method !== 'GET' && req.method !== 'HEAD') {
return proxy_to_backend(req, res, null);
}
// 2. Session cookie present: user has an active session, always pass through
if (has_session_cookie(req)) {
return proxy_to_backend(req, res, null);
}
// 3. Redis unavailable: proxy straight through (fail-open)
if (!redis_available) {
return proxy_to_backend(req, res, null);
}
// 4. Compute cache key and check Redis
const cache_key = make_cache_key(req.url);
try {
const cached = await redis_client.get(cache_key);
if (cached) {
const entry = JSON.parse(cached);
// Check If-None-Match for 304 Not Modified
const if_none_match = req.headers['if-none-match'];
if (if_none_match && if_none_match === entry.etag) {
res.writeHead(304, {
'ETag': entry.etag,
'X-FPC-Cache': 'HIT',
});
return res.end();
}
// Cached redirect
if (entry.status_code >= 300 && entry.status_code < 400 && entry.location) {
res.writeHead(entry.status_code, {
'Location': entry.location,
'ETag': entry.etag,
'X-FPC-Cache': 'HIT',
});
return res.end();
}
// Serve cached HTML
res.writeHead(entry.status_code || 200, {
'Content-Type': entry.content_type || 'text/html; charset=UTF-8',
'ETag': entry.etag,
'X-FPC-Cache': 'HIT',
});
return res.end(entry.html);
}
} catch (err) {
console.error('[fpc] Redis read error:', err.message);
}
// 5. Cache MISS — proxy to backend and potentially cache response
// Only GET requests can populate the cache (HEAD has no body to cache)
return proxy_to_backend(req, res, req.method === 'GET' ? cache_key : null);
});
// ---------------------------------------------------------------------------
// Startup
// ---------------------------------------------------------------------------
async function start() {
const { createClient } = require('redis');
// Connect to Redis (DB 0 — cache database with LRU eviction)
redis_client = createClient({
socket: { host: REDIS_HOST, port: REDIS_PORT },
password: REDIS_PASSWORD,
});
redis_client.on('error', (err) => {
if (redis_available) {
console.error('[fpc] Redis error:', err.message);
}
redis_available = false;
});
redis_client.on('ready', () => {
redis_available = true;
console.log(`[fpc] Connected to Redis at ${REDIS_HOST}:${REDIS_PORT}`);
});
try {
await redis_client.connect();
} catch (err) {
console.error('[fpc] Redis connection failed:', err.message);
console.log('[fpc] Running in pass-through mode (no caching)');
redis_available = false;
}
// Load build key and watch for changes
load_build_key();
watch_build_key();
// Start HTTP server
server.listen(FPC_PORT, () => {
console.log(`[fpc] FPC proxy listening on port ${FPC_PORT}`);
console.log(`[fpc] Backend: ${BACKEND_HOST}:${BACKEND_PORT}`);
console.log(`[fpc] Build key: ${build_key}`);
});
}
// ---------------------------------------------------------------------------
// Graceful shutdown
// ---------------------------------------------------------------------------
async function shutdown(signal) {
console.log(`[fpc] Received ${signal}, shutting down...`);
server.close();
if (redis_client) {
try {
await redis_client.quit();
} catch (err) {
// Ignore
}
}
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
start();

61
bin/ssr-server.js Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* RSpade SSR Server
*
* Thin wrapper around @jqhtml/ssr server.
* Starts a long-running Node process that renders jqhtml components to HTML.
* Managed by Rsx_SSR.php — spawned on-demand, communicates via Unix socket.
*
* Usage:
* node system/bin/ssr-server.js --socket=/path/to/ssr-server.sock
*/
const path = require('path');
const { SSRServer } = require(path.join(__dirname, '..', 'node_modules', '@jqhtml', 'ssr', 'src', 'server.js'));
// Parse --socket argument
let socketPath = null;
for (let i = 2; i < process.argv.length; i++) {
if (process.argv[i].startsWith('--socket=')) {
socketPath = process.argv[i].split('=')[1];
}
}
if (!socketPath) {
socketPath = path.join(__dirname, '..', 'storage', 'rsx-tmp', 'ssr-server.sock');
}
const server = new SSRServer({
maxBundles: 10,
defaultTimeout: 30000
});
(async () => {
try {
await server.listenUnix(socketPath);
} catch (err) {
console.error('[SSR] Failed to start:', err.message);
process.exit(1);
}
})();
// Ignore SIGHUP so server survives terminal disconnect
process.on('SIGHUP', () => {});
process.on('SIGINT', async () => {
await server.stop();
process.exit(0);
});
process.on('SIGTERM', async () => {
await server.stop();
process.exit(0);
});
process.on('unhandledRejection', (err) => {
console.error('[SSR] Unhandled rejection:', err);
});
process.on('uncaughtException', (err) => {
console.error('[SSR] Uncaught exception:', err);
});

View File

@@ -524,8 +524,9 @@ For mechanical thinkers who see structure, not visuals. Write `<User_Card>` not
**Interpolation**: `<%= escaped %>` | `<%!= unescaped %>` | `<%br= newlines %>` | `<% javascript %>`
**Conditional Attributes** `<input <% if (this.args.required) { %>required="required"<% } %> />`
**Child Content**: `<%= content() %>` - Renders whatever the caller puts between opening/closing tags. Essential for wrapper components: `<Define:Card tag="div" class="card"><%= content() %></Define:Card>` then `<Card><p>Hello</p></Card>`. Distinct from named `<Slot:name>` — content() is the default/unnamed slot.
**Inline Logic**: `<% this.handler = () => action(); %>` then `@click=this.handler` - No JS file needed for simple components
**Event Handlers**: `@click=this.method` (unquoted) - Methods defined inline or in companion .js
**Event Handlers**: `@click=this.method` (unquoted) - Methods defined inline or in companion .js. **Placement**: works on child HTML elements inside the template (`<button @click=this.handler>`), does NOT work on `<Define>` itself (Define attributes are component args, not DOM attributes). To bind the root element: `<% this.$.click(() => { ... }); %>` in an inline `<% %>` block.
**Validation**: `<% if (!this.args.required) throw new Error('Missing arg'); %>` - Fail loud in template
### Simple Components (No JS File)
@@ -732,6 +733,8 @@ this.$sid('result_container').component('My_Component', {
- `this.state` for UI state, `this.args` + `reload()` for refetch
- `Controller.method()` not `$.ajax()` - #[Ajax_Endpoint] auto-callable
- `on_create/render/stop` sync; `this.sid()` → component, `$(el).component()` → component
- `@click` goes on child elements, NOT on `<Define>` — for root element clicks use `<% this.$.click(() => {}); %>`
- Wrapper components use `<%= content() %>` to render caller's child content
---
@@ -754,6 +757,15 @@ Pattern recognition:
Built-in dialogs: `Modal.alert()`, `Modal.confirm()`, `Modal.prompt()`, `Modal.select()`, `Modal.error()`.
**ARGUMENT OVERLOADING**: 1 arg = body only (default title). 2+ args = first arg is TITLE, second is BODY. Easy to get backwards.
```javascript
await Modal.alert('Message'); // body only (title defaults)
await Modal.alert('Title', 'Message body'); // title + body
await Modal.confirm('Delete this?'); // body only
await Modal.confirm('Delete', 'Are you sure?'); // title + body
```
Form modals: `Modal.form({title, component, on_submit})` - component must implement `vals()`.
Reusable modals: Extend `Modal_Abstract`, implement static `show()`.

24
node_modules/.package-lock.json generated vendored
View File

@@ -2224,9 +2224,9 @@
}
},
"node_modules/@jqhtml/core": {
"version": "2.3.41",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/core/-/core-2.3.41.tgz",
"integrity": "sha512-Owf8Rf7yjG+WSRCPTXtTg+pFpWbTB+MnB/g2Clo6rVWZ5JxEqFZfmKIDx6lSX30pz16ph3RShe9Ijjc8V89S3w==",
"version": "2.3.42",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/core/-/core-2.3.42.tgz",
"integrity": "sha512-am+iSzLuM3yuTQIaguep3kQ1eFB/jOAeB+C26aYoQyuH4E15K3AvPvy7VfBLP5+80WeIZP7oFiNHeJ6OMKg8iA==",
"license": "MIT",
"dependencies": {
"@rollup/plugin-node-resolve": "^16.0.1",
@@ -2250,9 +2250,9 @@
}
},
"node_modules/@jqhtml/parser": {
"version": "2.3.41",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/parser/-/parser-2.3.41.tgz",
"integrity": "sha512-q6pT+eqWQf0qEgxzb61nERro5NkIeBnu/DQPUqRNZdywAqam8AHYlwzA5n54BlghJ6m/61DVeRMSHoVu1UV6lA==",
"version": "2.3.42",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/parser/-/parser-2.3.42.tgz",
"integrity": "sha512-sIj+wG6X3SdJLNchL3NpptN6Osh+WdB8CQR8SkzTcjZ+AvI7O/WNdxdDnQWRU3N51sIfk413v0searcjlMZKcg==",
"license": "MIT",
"dependencies": {
"@types/jest": "^29.5.11",
@@ -2290,9 +2290,9 @@
}
},
"node_modules/@jqhtml/ssr": {
"version": "2.3.41",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/ssr/-/ssr-2.3.41.tgz",
"integrity": "sha512-9uNQ7QaaBBU49ncEKxv9uoajfxe3/vt1wLOMrex81oqKB1PHFIkfQbQ1QcNakYgDTXMFkXKinH0O3qEROH9Lxw==",
"version": "2.3.42",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/ssr/-/ssr-2.3.42.tgz",
"integrity": "sha512-0glVS5mIPD+mB6X1hyzwQSVFXNs4UoUj/dUDZ/nfClsP2jdy+nLcpHxO0NL5N4phA3NOytOQ34sePp5Sx0a/gw==",
"license": "MIT",
"dependencies": {
"jquery": "^3.7.1",
@@ -2386,9 +2386,9 @@
}
},
"node_modules/@jqhtml/vscode-extension": {
"version": "2.3.41",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.41.tgz",
"integrity": "sha512-CB3tIppMT3cVLiOIAAxymMtLAae2FJfkf6aFSkQOiONK47h10k2/QkkXFJwXyRRnzbw+ijuhBCDodiLlJtt8aw==",
"version": "2.3.42",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.42.tgz",
"integrity": "sha512-+FASm90uV9SgSRtFRQiUelQ7JShx646XHg/SNzf43TaNaOMzz2SDz7fQS/4IbkYD2Qwa9vFPs5vTUKVc34rtRA==",
"license": "MIT",
"engines": {
"vscode": "^1.74.0"

View File

@@ -1 +1 @@
{"version":3,"file":"component.d.ts","sourceRoot":"","sources":["../src/component.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"component.d.ts","sourceRoot":"","sources":["../src/component.ts"],"names":[],"mappings":"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"}

View File

@@ -2837,6 +2837,141 @@ class Component_Queue {
}
}
/**
* JQHTML SSR Preload Data System
*
* Captures component data during SSR rendering and replays it on the client
* to skip redundant on_load() calls during hydration.
*
* Server-side: start_data_capture() → render → get_captured_data()
* Client-side: set_preload_data(entries) → components boot with preloaded data → clear_preload_data()
*/
// --- Capture (Server Side) ---
let _capture_enabled = false;
let _capture_buffer = [];
// Track captured keys to avoid duplicates from Load Coordinator followers
let _captured_keys = new Set();
/**
* Enable data capture mode. After this call, every component that completes
* on_load() has its final this.data recorded.
*
* Idempotent — calling twice is fine.
*/
function start_data_capture() {
_capture_enabled = true;
_capture_buffer = [];
_captured_keys.clear();
}
/**
* Record a component's data after on_load() completes.
* Called internally from _apply_load_result().
*
* @param component_name - Component name
* @param args - Component args at time of on_load()
* @param data - this.data after on_load() completed
* @param cache_key - The cache key for deduplication (null = always capture)
*/
function capture_component_data(component_name, args, data, cache_key) {
if (!_capture_enabled)
return;
// Deduplicate: if Load Coordinator already captured this key, skip
if (cache_key !== null) {
if (_captured_keys.has(cache_key))
return;
_captured_keys.add(cache_key);
}
// Deep clone args and data to plain objects (no proxies, no class instances)
try {
const cloned_args = JSON.parse(JSON.stringify(args));
const cloned_data = JSON.parse(JSON.stringify(data));
_capture_buffer.push({
component: component_name,
args: cloned_args,
data: cloned_data,
});
}
catch (error) {
// Non-serializable data — skip capture for this component
if (typeof console !== 'undefined') {
console.warn(`[JQHTML SSR Capture] Failed to serialize data for ${component_name}:`, error);
}
}
}
/**
* Returns all captured component data and resets the capture buffer.
* One-shot: calling clears the buffer.
*/
function get_captured_data() {
const result = _capture_buffer;
_capture_buffer = [];
_captured_keys.clear();
return result;
}
/**
* Check if data capture is currently enabled.
*/
function is_capture_enabled() {
return _capture_enabled;
}
/**
* Stop data capture and clear all state.
*/
function stop_data_capture() {
_capture_enabled = false;
_capture_buffer = [];
_captured_keys.clear();
}
// --- Preload (Client Side) ---
let _preload_cache = new Map();
/**
* Seed the preload cache with SSR-captured data.
* Must be called before any component _load() runs.
*
* @param entries - Array of { component, args, data } entries from get_captured_data()
*/
function set_preload_data(entries) {
if (!entries || entries.length === 0)
return;
_preload_cache.clear();
for (const entry of entries) {
// Generate key using the same algorithm as Load Coordinator
const result = Load_Coordinator.generate_invocation_key(entry.component, entry.args);
if (result.key !== null) {
_preload_cache.set(result.key, entry.data);
}
}
}
/**
* Check if preloaded data exists for a given cache key.
* If found, returns the data and removes the entry (one-shot).
*
* @param cache_key - The cache key (same format as Load Coordinator)
* @returns The preloaded data, or null if no match
*/
function consume_preload_data(cache_key) {
if (_preload_cache.size === 0)
return null;
const data = _preload_cache.get(cache_key);
if (data !== undefined) {
_preload_cache.delete(cache_key);
return data;
}
return null;
}
/**
* Clear any remaining unconsumed preload entries.
* Call after initial page hydration is complete.
*/
function clear_preload_data() {
_preload_cache.clear();
}
/**
* Check if any preload data is available (for fast early exit in _load).
*/
function has_preload_data() {
return _preload_cache.size > 0;
}
/**
* JQHTML v2 Component Base Class
*
@@ -3515,7 +3650,7 @@ class Jqhtml_Component {
}
return;
}
// Check if component implements cache_id() for custom cache key
// Generate cache key (needed for both preload check and load deduplication)
let cache_key = null;
let uncacheable_property;
if (typeof this.cache_id === 'function') {
@@ -3534,6 +3669,19 @@ class Jqhtml_Component {
cache_key = result.key;
uncacheable_property = result.uncacheable_property;
}
// SSR preload check: if preloaded data exists for this component+args, use it
if (cache_key !== null && has_preload_data()) {
const preloaded_data = consume_preload_data(cache_key);
if (preloaded_data !== null) {
this._cache_key = cache_key;
if (window.jqhtml?.debug?.verbose) {
console.log(`[SSR Preload] Component ${this._cid} (${this.component_name()}) using preloaded data`, { cache_key });
}
const data_before_load = JSON.stringify(this.data);
await this._apply_load_result(preloaded_data, data_before_load);
return;
}
}
// Store cache key for later use
this._cache_key = cache_key;
// If cache_key is null, args are not serializable - skip load deduplication and caching
@@ -3653,6 +3801,10 @@ class Jqhtml_Component {
}
// Freeze this.data
this.__data_frozen = true;
// SSR data capture: record this component's data for preloading
if (is_capture_enabled() && this._has_on_load()) {
capture_component_data(this.component_name(), this.args, this.data, this._cache_key);
}
// Calculate if data changed
const data_after_load = JSON.stringify(this.data);
const data_changed = data_before_load !== null && data_after_load !== data_before_load;
@@ -5336,7 +5488,7 @@ function init(jQuery) {
}
}
// Version - will be replaced during build with actual version from package.json
const version = '2.3.41';
const version = '2.3.42';
// Default export with all functionality
const jqhtml = {
// Core
@@ -5433,7 +5585,13 @@ const jqhtml = {
// Required for ES6 class instances to be stored and restored from localStorage
register_cache_class,
// Boot - hydrate server-rendered component placeholders
boot
boot,
// SSR Preload - capture data during SSR, replay on client to skip on_load()
start_data_capture,
get_captured_data,
stop_data_capture,
set_preload_data,
clear_preload_data
};
// Auto-register on window for browser environments
// This is REQUIRED for compiled templates which use window.jqhtml.register_template()
@@ -5470,12 +5628,14 @@ exports.LifecycleManager = LifecycleManager;
exports.Load_Coordinator = Load_Coordinator;
exports.applyDebugDelay = applyDebugDelay;
exports.boot = boot;
exports.clear_preload_data = clear_preload_data;
exports.create_component = create_component;
exports.default = jqhtml;
exports.devWarn = devWarn;
exports.escape_html = escape_html;
exports.escape_html_nl2br = escape_html_nl2br;
exports.extract_slots = extract_slots;
exports.get_captured_data = get_captured_data;
exports.get_component_class = get_component_class;
exports.get_component_names = get_component_names;
exports.get_registered_templates = get_registered_templates;
@@ -5497,5 +5657,8 @@ exports.register_cache_class = register_cache_class;
exports.register_component = register_component;
exports.register_template = register_template;
exports.render_template = render_template;
exports.set_preload_data = set_preload_data;
exports.start_data_capture = start_data_capture;
exports.stop_data_capture = stop_data_capture;
exports.version = version;
//# sourceMappingURL=index.cjs.map

File diff suppressed because one or more lines are too long

View File

@@ -29,6 +29,9 @@ export { Jqhtml_Local_Storage, register_cache_class };
export type { CacheMode } from './local-storage.js';
import { Load_Coordinator } from './load-coordinator.js';
export { Load_Coordinator };
import { start_data_capture, get_captured_data, stop_data_capture, set_preload_data, clear_preload_data } from './preload-data.js';
export { start_data_capture, get_captured_data, stop_data_capture, set_preload_data, clear_preload_data };
export type { PreloadEntry } from './preload-data.js';
export declare const version = "__VERSION__";
export interface DebugSettings {
verbose?: boolean;
@@ -89,6 +92,11 @@ declare const jqhtml: {
get_cache_mode(): import("./local-storage.js").CacheMode;
register_cache_class: typeof register_cache_class;
boot: typeof boot;
start_data_capture: typeof start_data_capture;
get_captured_data: typeof get_captured_data;
stop_data_capture: typeof stop_data_capture;
set_preload_data: typeof set_preload_data;
clear_preload_data: typeof clear_preload_data;
};
export default jqhtml;
//# sourceMappingURL=index.d.ts.map

View File

@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}

View File

@@ -2833,6 +2833,141 @@ class Component_Queue {
}
}
/**
* JQHTML SSR Preload Data System
*
* Captures component data during SSR rendering and replays it on the client
* to skip redundant on_load() calls during hydration.
*
* Server-side: start_data_capture() → render → get_captured_data()
* Client-side: set_preload_data(entries) → components boot with preloaded data → clear_preload_data()
*/
// --- Capture (Server Side) ---
let _capture_enabled = false;
let _capture_buffer = [];
// Track captured keys to avoid duplicates from Load Coordinator followers
let _captured_keys = new Set();
/**
* Enable data capture mode. After this call, every component that completes
* on_load() has its final this.data recorded.
*
* Idempotent — calling twice is fine.
*/
function start_data_capture() {
_capture_enabled = true;
_capture_buffer = [];
_captured_keys.clear();
}
/**
* Record a component's data after on_load() completes.
* Called internally from _apply_load_result().
*
* @param component_name - Component name
* @param args - Component args at time of on_load()
* @param data - this.data after on_load() completed
* @param cache_key - The cache key for deduplication (null = always capture)
*/
function capture_component_data(component_name, args, data, cache_key) {
if (!_capture_enabled)
return;
// Deduplicate: if Load Coordinator already captured this key, skip
if (cache_key !== null) {
if (_captured_keys.has(cache_key))
return;
_captured_keys.add(cache_key);
}
// Deep clone args and data to plain objects (no proxies, no class instances)
try {
const cloned_args = JSON.parse(JSON.stringify(args));
const cloned_data = JSON.parse(JSON.stringify(data));
_capture_buffer.push({
component: component_name,
args: cloned_args,
data: cloned_data,
});
}
catch (error) {
// Non-serializable data — skip capture for this component
if (typeof console !== 'undefined') {
console.warn(`[JQHTML SSR Capture] Failed to serialize data for ${component_name}:`, error);
}
}
}
/**
* Returns all captured component data and resets the capture buffer.
* One-shot: calling clears the buffer.
*/
function get_captured_data() {
const result = _capture_buffer;
_capture_buffer = [];
_captured_keys.clear();
return result;
}
/**
* Check if data capture is currently enabled.
*/
function is_capture_enabled() {
return _capture_enabled;
}
/**
* Stop data capture and clear all state.
*/
function stop_data_capture() {
_capture_enabled = false;
_capture_buffer = [];
_captured_keys.clear();
}
// --- Preload (Client Side) ---
let _preload_cache = new Map();
/**
* Seed the preload cache with SSR-captured data.
* Must be called before any component _load() runs.
*
* @param entries - Array of { component, args, data } entries from get_captured_data()
*/
function set_preload_data(entries) {
if (!entries || entries.length === 0)
return;
_preload_cache.clear();
for (const entry of entries) {
// Generate key using the same algorithm as Load Coordinator
const result = Load_Coordinator.generate_invocation_key(entry.component, entry.args);
if (result.key !== null) {
_preload_cache.set(result.key, entry.data);
}
}
}
/**
* Check if preloaded data exists for a given cache key.
* If found, returns the data and removes the entry (one-shot).
*
* @param cache_key - The cache key (same format as Load Coordinator)
* @returns The preloaded data, or null if no match
*/
function consume_preload_data(cache_key) {
if (_preload_cache.size === 0)
return null;
const data = _preload_cache.get(cache_key);
if (data !== undefined) {
_preload_cache.delete(cache_key);
return data;
}
return null;
}
/**
* Clear any remaining unconsumed preload entries.
* Call after initial page hydration is complete.
*/
function clear_preload_data() {
_preload_cache.clear();
}
/**
* Check if any preload data is available (for fast early exit in _load).
*/
function has_preload_data() {
return _preload_cache.size > 0;
}
/**
* JQHTML v2 Component Base Class
*
@@ -3511,7 +3646,7 @@ class Jqhtml_Component {
}
return;
}
// Check if component implements cache_id() for custom cache key
// Generate cache key (needed for both preload check and load deduplication)
let cache_key = null;
let uncacheable_property;
if (typeof this.cache_id === 'function') {
@@ -3530,6 +3665,19 @@ class Jqhtml_Component {
cache_key = result.key;
uncacheable_property = result.uncacheable_property;
}
// SSR preload check: if preloaded data exists for this component+args, use it
if (cache_key !== null && has_preload_data()) {
const preloaded_data = consume_preload_data(cache_key);
if (preloaded_data !== null) {
this._cache_key = cache_key;
if (window.jqhtml?.debug?.verbose) {
console.log(`[SSR Preload] Component ${this._cid} (${this.component_name()}) using preloaded data`, { cache_key });
}
const data_before_load = JSON.stringify(this.data);
await this._apply_load_result(preloaded_data, data_before_load);
return;
}
}
// Store cache key for later use
this._cache_key = cache_key;
// If cache_key is null, args are not serializable - skip load deduplication and caching
@@ -3649,6 +3797,10 @@ class Jqhtml_Component {
}
// Freeze this.data
this.__data_frozen = true;
// SSR data capture: record this component's data for preloading
if (is_capture_enabled() && this._has_on_load()) {
capture_component_data(this.component_name(), this.args, this.data, this._cache_key);
}
// Calculate if data changed
const data_after_load = JSON.stringify(this.data);
const data_changed = data_before_load !== null && data_after_load !== data_before_load;
@@ -5332,7 +5484,7 @@ function init(jQuery) {
}
}
// Version - will be replaced during build with actual version from package.json
const version = '2.3.41';
const version = '2.3.42';
// Default export with all functionality
const jqhtml = {
// Core
@@ -5429,7 +5581,13 @@ const jqhtml = {
// Required for ES6 class instances to be stored and restored from localStorage
register_cache_class,
// Boot - hydrate server-rendered component placeholders
boot
boot,
// SSR Preload - capture data during SSR, replay on client to skip on_load()
start_data_capture,
get_captured_data,
stop_data_capture,
set_preload_data,
clear_preload_data
};
// Auto-register on window for browser environments
// This is REQUIRED for compiled templates which use window.jqhtml.register_template()
@@ -5459,5 +5617,5 @@ if (typeof window !== 'undefined' && !window.jqhtml) {
}
}
export { Jqhtml_Component, LifecycleManager as Jqhtml_LifecycleManager, Jqhtml_Local_Storage, LifecycleManager, Load_Coordinator, applyDebugDelay, boot, create_component, jqhtml as default, devWarn, escape_html, escape_html_nl2br, extract_slots, get_component_class, get_component_names, get_registered_templates, get_template, get_template_by_class, handleComponentError, has_component, init, init_jquery_plugin, isSequentialProcessing, list_components, logDataChange, logDispatch, logInstruction, logLifecycle, process_instructions, register, register_cache_class, register_component, register_template, render_template, version };
export { Jqhtml_Component, LifecycleManager as Jqhtml_LifecycleManager, Jqhtml_Local_Storage, LifecycleManager, Load_Coordinator, applyDebugDelay, boot, clear_preload_data, create_component, jqhtml as default, devWarn, escape_html, escape_html_nl2br, extract_slots, get_captured_data, get_component_class, get_component_names, get_registered_templates, get_template, get_template_by_class, handleComponentError, has_component, init, init_jquery_plugin, isSequentialProcessing, list_components, logDataChange, logDispatch, logInstruction, logLifecycle, process_instructions, register, register_cache_class, register_component, register_template, render_template, set_preload_data, start_data_capture, stop_data_capture, version };
//# sourceMappingURL=index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
/**
* JQHTML Core v2.3.41
* JQHTML Core v2.3.42
* (c) 2025 JQHTML Team
* Released under the MIT License
*/
@@ -2838,6 +2838,141 @@ class Component_Queue {
}
}
/**
* JQHTML SSR Preload Data System
*
* Captures component data during SSR rendering and replays it on the client
* to skip redundant on_load() calls during hydration.
*
* Server-side: start_data_capture() → render → get_captured_data()
* Client-side: set_preload_data(entries) → components boot with preloaded data → clear_preload_data()
*/
// --- Capture (Server Side) ---
let _capture_enabled = false;
let _capture_buffer = [];
// Track captured keys to avoid duplicates from Load Coordinator followers
let _captured_keys = new Set();
/**
* Enable data capture mode. After this call, every component that completes
* on_load() has its final this.data recorded.
*
* Idempotent — calling twice is fine.
*/
function start_data_capture() {
_capture_enabled = true;
_capture_buffer = [];
_captured_keys.clear();
}
/**
* Record a component's data after on_load() completes.
* Called internally from _apply_load_result().
*
* @param component_name - Component name
* @param args - Component args at time of on_load()
* @param data - this.data after on_load() completed
* @param cache_key - The cache key for deduplication (null = always capture)
*/
function capture_component_data(component_name, args, data, cache_key) {
if (!_capture_enabled)
return;
// Deduplicate: if Load Coordinator already captured this key, skip
if (cache_key !== null) {
if (_captured_keys.has(cache_key))
return;
_captured_keys.add(cache_key);
}
// Deep clone args and data to plain objects (no proxies, no class instances)
try {
const cloned_args = JSON.parse(JSON.stringify(args));
const cloned_data = JSON.parse(JSON.stringify(data));
_capture_buffer.push({
component: component_name,
args: cloned_args,
data: cloned_data,
});
}
catch (error) {
// Non-serializable data — skip capture for this component
if (typeof console !== 'undefined') {
console.warn(`[JQHTML SSR Capture] Failed to serialize data for ${component_name}:`, error);
}
}
}
/**
* Returns all captured component data and resets the capture buffer.
* One-shot: calling clears the buffer.
*/
function get_captured_data() {
const result = _capture_buffer;
_capture_buffer = [];
_captured_keys.clear();
return result;
}
/**
* Check if data capture is currently enabled.
*/
function is_capture_enabled() {
return _capture_enabled;
}
/**
* Stop data capture and clear all state.
*/
function stop_data_capture() {
_capture_enabled = false;
_capture_buffer = [];
_captured_keys.clear();
}
// --- Preload (Client Side) ---
let _preload_cache = new Map();
/**
* Seed the preload cache with SSR-captured data.
* Must be called before any component _load() runs.
*
* @param entries - Array of { component, args, data } entries from get_captured_data()
*/
function set_preload_data(entries) {
if (!entries || entries.length === 0)
return;
_preload_cache.clear();
for (const entry of entries) {
// Generate key using the same algorithm as Load Coordinator
const result = Load_Coordinator.generate_invocation_key(entry.component, entry.args);
if (result.key !== null) {
_preload_cache.set(result.key, entry.data);
}
}
}
/**
* Check if preloaded data exists for a given cache key.
* If found, returns the data and removes the entry (one-shot).
*
* @param cache_key - The cache key (same format as Load Coordinator)
* @returns The preloaded data, or null if no match
*/
function consume_preload_data(cache_key) {
if (_preload_cache.size === 0)
return null;
const data = _preload_cache.get(cache_key);
if (data !== undefined) {
_preload_cache.delete(cache_key);
return data;
}
return null;
}
/**
* Clear any remaining unconsumed preload entries.
* Call after initial page hydration is complete.
*/
function clear_preload_data() {
_preload_cache.clear();
}
/**
* Check if any preload data is available (for fast early exit in _load).
*/
function has_preload_data() {
return _preload_cache.size > 0;
}
/**
* JQHTML v2 Component Base Class
*
@@ -3516,7 +3651,7 @@ class Jqhtml_Component {
}
return;
}
// Check if component implements cache_id() for custom cache key
// Generate cache key (needed for both preload check and load deduplication)
let cache_key = null;
let uncacheable_property;
if (typeof this.cache_id === 'function') {
@@ -3535,6 +3670,19 @@ class Jqhtml_Component {
cache_key = result.key;
uncacheable_property = result.uncacheable_property;
}
// SSR preload check: if preloaded data exists for this component+args, use it
if (cache_key !== null && has_preload_data()) {
const preloaded_data = consume_preload_data(cache_key);
if (preloaded_data !== null) {
this._cache_key = cache_key;
if (window.jqhtml?.debug?.verbose) {
console.log(`[SSR Preload] Component ${this._cid} (${this.component_name()}) using preloaded data`, { cache_key });
}
const data_before_load = JSON.stringify(this.data);
await this._apply_load_result(preloaded_data, data_before_load);
return;
}
}
// Store cache key for later use
this._cache_key = cache_key;
// If cache_key is null, args are not serializable - skip load deduplication and caching
@@ -3654,6 +3802,10 @@ class Jqhtml_Component {
}
// Freeze this.data
this.__data_frozen = true;
// SSR data capture: record this component's data for preloading
if (is_capture_enabled() && this._has_on_load()) {
capture_component_data(this.component_name(), this.args, this.data, this._cache_key);
}
// Calculate if data changed
const data_after_load = JSON.stringify(this.data);
const data_changed = data_before_load !== null && data_after_load !== data_before_load;
@@ -5337,7 +5489,7 @@ function init(jQuery) {
}
}
// Version - will be replaced during build with actual version from package.json
const version = '2.3.41';
const version = '2.3.42';
// Default export with all functionality
const jqhtml = {
// Core
@@ -5434,7 +5586,13 @@ const jqhtml = {
// Required for ES6 class instances to be stored and restored from localStorage
register_cache_class,
// Boot - hydrate server-rendered component placeholders
boot
boot,
// SSR Preload - capture data during SSR, replay on client to skip on_load()
start_data_capture,
get_captured_data,
stop_data_capture,
set_preload_data,
clear_preload_data
};
// Auto-register on window for browser environments
// This is REQUIRED for compiled templates which use window.jqhtml.register_template()
@@ -5464,5 +5622,5 @@ if (typeof window !== 'undefined' && !window.jqhtml) {
}
}
export { Jqhtml_Component, LifecycleManager as Jqhtml_LifecycleManager, Jqhtml_Local_Storage, LifecycleManager, Load_Coordinator, applyDebugDelay, boot, create_component, jqhtml as default, devWarn, escape_html, escape_html_nl2br, extract_slots, get_component_class, get_component_names, get_registered_templates, get_template, get_template_by_class, handleComponentError, has_component, init, init_jquery_plugin, isSequentialProcessing, list_components, logDataChange, logDispatch, logInstruction, logLifecycle, process_instructions, register, register_cache_class, register_component, register_template, render_template, version };
export { Jqhtml_Component, LifecycleManager as Jqhtml_LifecycleManager, Jqhtml_Local_Storage, LifecycleManager, Load_Coordinator, applyDebugDelay, boot, clear_preload_data, create_component, jqhtml as default, devWarn, escape_html, escape_html_nl2br, extract_slots, get_captured_data, get_component_class, get_component_names, get_registered_templates, get_template, get_template_by_class, handleComponentError, has_component, init, init_jquery_plugin, isSequentialProcessing, list_components, logDataChange, logDispatch, logInstruction, logLifecycle, process_instructions, register, register_cache_class, register_component, register_template, render_template, set_preload_data, start_data_capture, stop_data_capture, version };
//# sourceMappingURL=jqhtml-core.esm.js.map

File diff suppressed because one or more lines are too long

69
node_modules/@jqhtml/core/dist/preload-data.d.ts generated vendored Executable file
View File

@@ -0,0 +1,69 @@
/**
* JQHTML SSR Preload Data System
*
* Captures component data during SSR rendering and replays it on the client
* to skip redundant on_load() calls during hydration.
*
* Server-side: start_data_capture() → render → get_captured_data()
* Client-side: set_preload_data(entries) → components boot with preloaded data → clear_preload_data()
*/
export interface PreloadEntry {
component: string;
args: Record<string, any>;
data: Record<string, any>;
}
/**
* Enable data capture mode. After this call, every component that completes
* on_load() has its final this.data recorded.
*
* Idempotent — calling twice is fine.
*/
export declare function start_data_capture(): void;
/**
* Record a component's data after on_load() completes.
* Called internally from _apply_load_result().
*
* @param component_name - Component name
* @param args - Component args at time of on_load()
* @param data - this.data after on_load() completed
* @param cache_key - The cache key for deduplication (null = always capture)
*/
export declare function capture_component_data(component_name: string, args: Record<string, any>, data: Record<string, any>, cache_key: string | null): void;
/**
* Returns all captured component data and resets the capture buffer.
* One-shot: calling clears the buffer.
*/
export declare function get_captured_data(): PreloadEntry[];
/**
* Check if data capture is currently enabled.
*/
export declare function is_capture_enabled(): boolean;
/**
* Stop data capture and clear all state.
*/
export declare function stop_data_capture(): void;
/**
* Seed the preload cache with SSR-captured data.
* Must be called before any component _load() runs.
*
* @param entries - Array of { component, args, data } entries from get_captured_data()
*/
export declare function set_preload_data(entries: PreloadEntry[] | null): void;
/**
* Check if preloaded data exists for a given cache key.
* If found, returns the data and removes the entry (one-shot).
*
* @param cache_key - The cache key (same format as Load Coordinator)
* @returns The preloaded data, or null if no match
*/
export declare function consume_preload_data(cache_key: string): Record<string, any> | null;
/**
* Clear any remaining unconsumed preload entries.
* Call after initial page hydration is complete.
*/
export declare function clear_preload_data(): void;
/**
* Check if any preload data is available (for fast early exit in _load).
*/
export declare function has_preload_data(): boolean;
//# sourceMappingURL=preload-data.d.ts.map

1
node_modules/@jqhtml/core/dist/preload-data.d.ts.map generated vendored Executable file
View File

@@ -0,0 +1 @@
{"version":3,"file":"preload-data.d.ts","sourceRoot":"","sources":["../src/preload-data.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAC1B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAC3B;AASD;;;;;GAKG;AACH,wBAAgB,kBAAkB,IAAI,IAAI,CAIzC;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,cAAc,EAAE,MAAM,EACtB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACzB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACzB,SAAS,EAAE,MAAM,GAAG,IAAI,GACvB,IAAI,CA4BN;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,YAAY,EAAE,CAKlD;AAED;;GAEG;AACH,wBAAgB,kBAAkB,IAAI,OAAO,CAE5C;AAED;;GAEG;AACH,wBAAgB,iBAAiB,IAAI,IAAI,CAIxC;AAMD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,IAAI,GAAG,IAAI,CAYrE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,CASlF;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC;AAED;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAE1C"}

View File

@@ -1,6 +1,6 @@
{
"name": "@jqhtml/core",
"version": "2.3.41",
"version": "2.3.42",
"description": "Core runtime library for JQHTML",
"type": "module",
"main": "./dist/index.js",

View File

@@ -1385,7 +1385,7 @@ export class CodeGenerator {
for (const [name, component] of this.components) {
code += `// Component: ${name}\n`;
code += `jqhtml_components.set('${name}', {\n`;
code += ` _jqhtml_version: '2.3.41',\n`; // Version will be replaced during build
code += ` _jqhtml_version: '2.3.42',\n`; // Version will be replaced during build
code += ` name: '${name}',\n`;
code += ` tag: '${component.tagName}',\n`;
code += ` defaultAttributes: ${this.serializeAttributeObject(component.defaultAttributes)},\n`;

View File

@@ -1,6 +1,6 @@
{
"name": "@jqhtml/parser",
"version": "2.3.41",
"version": "2.3.42",
"description": "JQHTML template parser - converts templates to JavaScript",
"type": "module",
"main": "dist/index.js",

136
node_modules/@jqhtml/ssr/README.md generated vendored
View File

@@ -361,6 +361,142 @@ Or flush specific bundle:
---
## SSR Preload API (Hydration Acceleration)
The SSR Preload API eliminates redundant `on_load()` calls during client hydration. When the SSR server already fetched data for a component, the client can skip the identical fetch and use the captured data directly.
**Without preload:** SSR fetches data → client boots → client fetches same data again (wasted round-trip).
**With preload:** SSR fetches data → captures it → client receives captured data → client boots with preloaded data → no redundant fetch.
### Server-Side: Capturing Data
During SSR rendering, enable data capture to record each component's loaded data:
```javascript
// 1. Enable capture before rendering
jqhtml.start_data_capture();
// 2. Render the component (on_load() runs, data is captured automatically)
// ... component renders normally ...
// 3. Retrieve captured data (one-shot: clears buffer after retrieval)
const captured = jqhtml.get_captured_data();
// Returns: [{ component: 'DashboardIndex', args: { user_id: 123 }, data: { items: [...] } }, ...]
// 4. Stop capture when done
jqhtml.stop_data_capture();
// 5. Include captured data in SSR response
return { html: renderedHtml, preload: captured };
```
**Capture rules:**
- Only components with `on_load()` are captured (static components excluded)
- Deduplicates by component name + args (Load Coordinator aware)
- `get_captured_data()` is one-shot: clears the buffer on retrieval
- `start_data_capture()` is idempotent (safe to call multiple times)
### Client-Side: Injecting Preloaded Data
Before components boot on the client, seed the preload cache with SSR-captured data:
```javascript
// 1. Inject preloaded data BEFORE components initialize
jqhtml.set_preload_data(ssrPreloadData);
// 2. Components boot → _load() finds preload cache hit → skips on_load() entirely
// ... components initialize normally, but with instant data ...
// 3. Clear unconsumed entries after hydration is complete
jqhtml.clear_preload_data();
```
**Preload rules:**
- `set_preload_data(entries)` must be called before components boot
- Each preload entry is consumed on first use (one-shot per key)
- `set_preload_data(null)` or `set_preload_data([])` is a no-op
- `clear_preload_data()` removes any unconsumed entries
### Complete Data Flow
```
SSR Server:
1. jqhtml.start_data_capture()
2. Render component (on_load() fetches data → CAPTURED)
3. captured = jqhtml.get_captured_data()
4. Return { html, preload: captured }
Client:
1. jqhtml.set_preload_data(ssr_preload)
2. Components boot → _load() hits preload cache → skips on_load()
3. jqhtml.clear_preload_data()
```
### Updated PHP Integration Example (with Preload)
```php
// Usage with preload data
$ssr = new JqhtmlSSR();
$result = $ssr->render('Dashboard_Index_Action', ['user_id' => 123], $bundles);
if ($result['status'] === 'success') {
$html = $result['payload']['html'];
$preload = json_encode($result['payload']['preload'] ?? []);
echo $html;
echo "<script>jqhtml.set_preload_data({$preload});</script>";
echo "<script src='/bundles/vendor.js'></script>";
echo "<script src='/bundles/app.js'></script>";
echo "<script>jqhtml.boot().then(() => jqhtml.clear_preload_data());</script>";
}
```
### Updated Node.js Client Example (with Preload)
```javascript
const response = await sendSSRRequest(9876, {
id: 'render-1',
type: 'render',
payload: {
bundles: [
{ id: 'vendor', content: vendorBundleContent },
{ id: 'app', content: appBundleContent }
],
component: 'Dashboard_Index_Action',
args: { user_id: 123 },
options: { baseUrl: 'http://localhost:3000' }
}
});
const { html, preload } = response.payload;
// Embed preload data in the page for client-side hydration
const pageHtml = `
${html}
<script>
jqhtml.set_preload_data(${JSON.stringify(preload || [])});
</script>
<script src="/bundles/vendor.js"></script>
<script src="/bundles/app.js"></script>
<script>
jqhtml.boot().then(() => jqhtml.clear_preload_data());
</script>
`;
```
### API Reference
| Method | Description |
|--------|-------------|
| `jqhtml.start_data_capture()` | Enable data capture mode (idempotent) |
| `jqhtml.get_captured_data()` | Returns captured entries and clears buffer (one-shot) |
| `jqhtml.stop_data_capture()` | Stops capture and clears all capture state |
| `jqhtml.set_preload_data(entries)` | Seeds preload cache with SSR-captured data |
| `jqhtml.clear_preload_data()` | Clears unconsumed preload entries |
---
## File Structure
```

View File

@@ -544,6 +544,69 @@ jqhtml-ssr --render \
---
## Preload Data Capture and Injection Protocol
The preload system captures component data during SSR and replays it on the client to skip redundant `on_load()` calls during hydration.
### Entry Format
Each captured entry has the following structure:
```json
{
"component": "DashboardIndex",
"args": { "user_id": 123 },
"data": { "items": [...], "total": 42 }
}
```
| Field | Type | Description |
|-------|------|-------------|
| `component` | string | Component name |
| `args` | object | Component arguments (as passed at creation) |
| `data` | object | The component's `this.data` after `on_load()` completed |
### Key Generation
Preload entries are matched by a composite key of `component` + `args`. The key generation algorithm:
1. Component name is used as-is (string)
2. Args are serialized deterministically (stable JSON stringification)
3. Combined key: `component + ":" + serialized_args`
This means two components with the same name and identical args share a preload entry, which is consistent with Load Coordinator deduplication behavior.
### One-Shot Semantics
Both capture and preload use one-shot consumption:
- **`get_captured_data()`** — Returns all captured entries and clears the capture buffer. Calling again returns an empty array until new components are captured.
- **Preload entries** — Each entry is consumed on first use. When `_load()` finds a preload cache hit, it removes that entry. A second component with the same key will not find a preload hit and will call `on_load()` normally.
### Integration Points in `_load()`
The preload system integrates into the component `_load()` method with the following priority:
1. **Preload cache check** (highest priority) — If `set_preload_data()` was called and an entry matches this component's name + args, apply the data via `_apply_load_result()` and skip `on_load()` entirely.
2. **Load Coordinator check** — If another instance of the same component + args is already loading, wait for its result.
3. **Normal `on_load()`** — Call the component's `on_load()` method.
### Capture Integration in `_apply_load_result()`
When data capture is enabled (`start_data_capture()` was called), `_apply_load_result()` records the component's data if the component has an `on_load()` method. Static components (those without `on_load()`) are excluded from capture.
### API Summary
| Method | Side | Description |
|--------|------|-------------|
| `start_data_capture()` | Server | Enable capture mode (idempotent) |
| `get_captured_data()` | Server | Return and clear captured entries |
| `stop_data_capture()` | Server | Stop capture, clear all state |
| `set_preload_data(entries)` | Client | Seed preload cache; null/empty is no-op |
| `clear_preload_data()` | Client | Clear unconsumed preload entries |
---
## Future Considerations
### Streaming SSR

View File

@@ -1,6 +1,6 @@
{
"name": "@jqhtml/ssr",
"version": "2.3.41",
"version": "2.3.42",
"description": "Server-Side Rendering for JQHTML components - renders components to HTML for SEO",
"main": "src/index.js",
"bin": {

View File

@@ -149,6 +149,58 @@ class SSREnvironment {
});
}
/**
* Execute JavaScript code wrapped in an IIFE to prevent class declarations
* from leaking into the global scope across requests.
*
* Without IIFE wrapping, `class Foo {}` in vm.runInThisContext() persists
* in the global context, causing "Identifier already declared" errors on
* subsequent requests that load the same bundles.
*
* @param {string} code - JavaScript code to execute
* @param {string} filename - Filename for error reporting
*/
executeScoped(code, filename = 'scoped-bundle.js') {
if (!this.initialized) {
throw new Error('SSR environment not initialized. Call init() first.');
}
const cleanCode = code.replace(/\/\/# sourceMappingURL=.*/g, '');
// Register Rsx on window inside the IIFE so boot() can access it.
// Class declarations are block-scoped inside the IIFE, but closures
// preserve the scope — so Rsx._rsx_core_boot() can still reference
// Manifest, Ajax, etc. by name when called from outside.
const registration = 'if (typeof Rsx !== "undefined") { window.Rsx = Rsx; }';
const wrappedCode = `(function() {\n${cleanCode}\n${registration}\n})();`;
vm.runInThisContext(wrappedCode, {
filename,
displayErrors: true
});
}
/**
* Boot the RSpade framework lifecycle.
*
* Calls Rsx._rsx_core_boot() which runs the full 10-phase initialization
* sequence: Ajax init, component registration, jqhtml cache setup, etc.
* Framework classes with browser-only APIs detect window.__SSR__ and skip
* their initialization via Rsx.is_ssr() guards.
*
* Must be called after executeScoped() loads the bundles.
*/
async boot() {
if (!this.initialized) {
throw new Error('SSR environment not initialized. Call init() first.');
}
const Rsx = global.Rsx || (global.window && global.window.Rsx);
if (Rsx && typeof Rsx._rsx_core_boot === 'function') {
await Rsx._rsx_core_boot();
}
}
/**
* Render a component to HTML
* @param {string} componentName - Component name to render
@@ -161,6 +213,12 @@ class SSREnvironment {
}
const $ = global.$;
const jqhtml = global.window.jqhtml;
// Start data capture for preload
if (jqhtml && typeof jqhtml.start_data_capture === 'function') {
jqhtml.start_data_capture();
}
// Create container
const $container = $('<div>');
@@ -188,10 +246,16 @@ class SSREnvironment {
// Get rendered HTML
const html = component.$.prop('outerHTML');
// Get captured preload data
let preload = [];
if (jqhtml && typeof jqhtml.get_captured_data === 'function') {
preload = jqhtml.get_captured_data();
}
// Export cache state
const cache = this.storage.exportAll();
return { html, cache };
return { html, cache, preload };
}
/**

View File

@@ -74,7 +74,7 @@ function parseRequest(message) {
};
}
const validTypes = ['render', 'render_spa', 'ping', 'flush_cache'];
const validTypes = ['render', 'render_spa', 'ping', 'flush_cache', 'shutdown'];
if (!validTypes.includes(parsed.type)) {
return {
ok: false,
@@ -396,12 +396,12 @@ function errorResponse(id, code, message, stack) {
* @param {object} timing - Timing info { total_ms, bundle_load_ms, render_ms }
* @returns {string} JSON string with newline
*/
function renderResponse(id, html, cache, timing) {
return successResponse(id, {
html,
cache,
timing
});
function renderResponse(id, html, cache, timing, preload) {
const payload = { html, cache, timing };
if (preload && preload.length > 0) {
payload.preload = preload;
}
return successResponse(id, payload);
}
/**
@@ -424,6 +424,15 @@ function flushCacheResponse(id, flushed) {
return successResponse(id, { flushed });
}
/**
* Create a shutdown response
* @param {string} id - Request ID
* @returns {string} JSON string with newline
*/
function shutdownResponse(id) {
return successResponse(id, { result: 'shutting down' });
}
/**
* MessageBuffer - Handles newline-delimited message framing
*
@@ -483,5 +492,6 @@ module.exports = {
renderSpaResponse,
pingResponse,
flushCacheResponse,
shutdownResponse,
MessageBuffer
};

View File

@@ -17,6 +17,7 @@ const {
renderSpaResponse,
pingResponse,
flushCacheResponse,
shutdownResponse,
errorResponse,
MessageBuffer
} = require('./protocol.js');
@@ -158,6 +159,12 @@ class SSRServer {
response = await this._handleRenderSpa(request);
break;
case 'shutdown':
socket.write(shutdownResponse(request.id));
await this.stop();
process.exit(0);
return;
default:
response = errorResponse(
request.id,
@@ -253,11 +260,15 @@ class SSRServer {
// Initialize environment
env.init();
// Execute bundle code
// Execute bundle code (scoped IIFE prevents class declaration leaks across requests)
const execStartTime = Date.now();
env.execute(bundleCode, `bundles:${cacheKey}`);
env.executeScoped(bundleCode, `bundles:${cacheKey}`);
bundleLoadMs += Date.now() - execStartTime;
// Boot the RSpade framework (runs full 10-phase lifecycle)
// Framework classes detect window.__SSR__ and skip browser-only init
await env.boot();
// Check if jqhtml loaded
if (!env.isJqhtmlReady()) {
throw new Error('jqhtml runtime not available after loading bundles');
@@ -284,7 +295,7 @@ class SSRServer {
total_ms: totalMs,
bundle_load_ms: bundleLoadMs,
render_ms: renderMs
});
}, result.preload);
} catch (err) {
// Determine error type
@@ -499,4 +510,14 @@ Options:
await server.stop();
process.exit(0);
});
process.on('SIGHUP', () => {});
process.on('unhandledRejection', (err) => {
console.error('[SSR] Unhandled rejection:', err);
});
process.on('uncaughtException', (err) => {
console.error('[SSR] Uncaught exception:', err);
});
}

View File

@@ -1 +1 @@
2.3.41
2.3.42

View File

@@ -2,7 +2,7 @@
"name": "@jqhtml/vscode-extension",
"displayName": "JQHTML",
"description": "Syntax highlighting and language support for JQHTML template files",
"version": "2.3.41",
"version": "2.3.42",
"publisher": "jqhtml",
"license": "MIT",
"publishConfig": {

24
package-lock.json generated
View File

@@ -2676,9 +2676,9 @@
}
},
"node_modules/@jqhtml/core": {
"version": "2.3.41",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/core/-/core-2.3.41.tgz",
"integrity": "sha512-Owf8Rf7yjG+WSRCPTXtTg+pFpWbTB+MnB/g2Clo6rVWZ5JxEqFZfmKIDx6lSX30pz16ph3RShe9Ijjc8V89S3w==",
"version": "2.3.42",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/core/-/core-2.3.42.tgz",
"integrity": "sha512-am+iSzLuM3yuTQIaguep3kQ1eFB/jOAeB+C26aYoQyuH4E15K3AvPvy7VfBLP5+80WeIZP7oFiNHeJ6OMKg8iA==",
"license": "MIT",
"dependencies": {
"@rollup/plugin-node-resolve": "^16.0.1",
@@ -2702,9 +2702,9 @@
}
},
"node_modules/@jqhtml/parser": {
"version": "2.3.41",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/parser/-/parser-2.3.41.tgz",
"integrity": "sha512-q6pT+eqWQf0qEgxzb61nERro5NkIeBnu/DQPUqRNZdywAqam8AHYlwzA5n54BlghJ6m/61DVeRMSHoVu1UV6lA==",
"version": "2.3.42",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/parser/-/parser-2.3.42.tgz",
"integrity": "sha512-sIj+wG6X3SdJLNchL3NpptN6Osh+WdB8CQR8SkzTcjZ+AvI7O/WNdxdDnQWRU3N51sIfk413v0searcjlMZKcg==",
"license": "MIT",
"dependencies": {
"@types/jest": "^29.5.11",
@@ -2742,9 +2742,9 @@
}
},
"node_modules/@jqhtml/ssr": {
"version": "2.3.41",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/ssr/-/ssr-2.3.41.tgz",
"integrity": "sha512-9uNQ7QaaBBU49ncEKxv9uoajfxe3/vt1wLOMrex81oqKB1PHFIkfQbQ1QcNakYgDTXMFkXKinH0O3qEROH9Lxw==",
"version": "2.3.42",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/ssr/-/ssr-2.3.42.tgz",
"integrity": "sha512-0glVS5mIPD+mB6X1hyzwQSVFXNs4UoUj/dUDZ/nfClsP2jdy+nLcpHxO0NL5N4phA3NOytOQ34sePp5Sx0a/gw==",
"license": "MIT",
"dependencies": {
"jquery": "^3.7.1",
@@ -2838,9 +2838,9 @@
}
},
"node_modules/@jqhtml/vscode-extension": {
"version": "2.3.41",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.41.tgz",
"integrity": "sha512-CB3tIppMT3cVLiOIAAxymMtLAae2FJfkf6aFSkQOiONK47h10k2/QkkXFJwXyRRnzbw+ijuhBCDodiLlJtt8aw==",
"version": "2.3.42",
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.42.tgz",
"integrity": "sha512-+FASm90uV9SgSRtFRQiUelQ7JShx646XHg/SNzf43TaNaOMzz2SDz7fQS/4IbkYD2Qwa9vFPs5vTUKVc34rtRA==",
"license": "MIT",
"engines": {
"vscode": "^1.74.0"

16
supervisor/fpc-proxy.conf Executable file
View File

@@ -0,0 +1,16 @@
[program:fpc-proxy]
command=/usr/bin/node /var/www/html/system/bin/fpc-proxy.js
directory=/var/www/html
autostart=true
autorestart=true
startsecs=10
stopwaitsecs=10
stdout_logfile=/var/log/supervisor/fpc-proxy.log
stderr_logfile=/var/log/supervisor/fpc-proxy-error.log
stdout_logfile_maxbytes=10MB
stderr_logfile_maxbytes=10MB
stdout_logfile_backups=5
stderr_logfile_backups=5
user=www-data
killasgroup=true
stopasgroup=true