Framework updates
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
|
||||||
@@ -297,6 +297,35 @@ class Dispatcher
|
|||||||
|
|
||||||
Debugger::console_debug('DISPATCH', 'Matched route to ' . $handler_class . '::' . $handler_method . ' params: ' . json_encode($params));
|
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
|
// Set current controller and action in Rsx for tracking
|
||||||
$route_type = $route_match['type'] ?? 'standard';
|
$route_type = $route_match['type'] ?? 'standard';
|
||||||
\App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method, $params, $route_type);
|
\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
|
// Convert result to response
|
||||||
$response = static::__build_response($result);
|
$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.)
|
// Apply response transformations (HEAD body stripping, etc.)
|
||||||
return static::__transform_response($response, $original_method);
|
return static::__transform_response($response, $original_method);
|
||||||
}
|
}
|
||||||
|
|||||||
105
app/RSpade/Core/FPC/Rsx_FPC.php
Executable file
105
app/RSpade/Core/FPC/Rsx_FPC.php
Executable file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\RSpade\Core\FPC;
|
||||||
|
|
||||||
|
use App\RSpade\Core\Manifest\Manifest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full Page Cache management utility
|
||||||
|
*
|
||||||
|
* Provides methods to clear FPC entries from Redis.
|
||||||
|
* The FPC is a Node.js reverse proxy that caches responses
|
||||||
|
* marked with #[FPC] attribute in Redis.
|
||||||
|
*
|
||||||
|
* Cache key format: fpc:{build_key}:{sha1(url)}
|
||||||
|
* Redis DB: 0 (shared cache database with LRU eviction)
|
||||||
|
*/
|
||||||
|
class Rsx_FPC
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Clear all FPC cache entries for the current build key
|
||||||
|
*
|
||||||
|
* @return int Number of deleted entries
|
||||||
|
*/
|
||||||
|
public static function clear(): int
|
||||||
|
{
|
||||||
|
$redis = self::_get_redis();
|
||||||
|
if (!$redis) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$build_key = Manifest::get_build_key();
|
||||||
|
$pattern = "fpc:{$build_key}:*";
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
// Use SCAN to avoid blocking Redis with KEYS
|
||||||
|
$iterator = null;
|
||||||
|
do {
|
||||||
|
$keys = $redis->scan($iterator, $pattern, 100);
|
||||||
|
if ($keys !== false && count($keys) > 0) {
|
||||||
|
$count += $redis->del($keys);
|
||||||
|
}
|
||||||
|
} while ($iterator > 0);
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear FPC cache for a specific URL
|
||||||
|
*
|
||||||
|
* @param string $url The URL path with optional query string (e.g., '/about' or '/search?q=test')
|
||||||
|
* @return bool True if entry was deleted
|
||||||
|
*/
|
||||||
|
public static function clear_url(string $url): bool
|
||||||
|
{
|
||||||
|
$redis = self::_get_redis();
|
||||||
|
if (!$redis) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$build_key = Manifest::get_build_key();
|
||||||
|
|
||||||
|
// Parse and sort query params to match the proxy's key generation
|
||||||
|
$parsed = parse_url($url);
|
||||||
|
$path = $parsed['path'] ?? $url;
|
||||||
|
|
||||||
|
if (!empty($parsed['query'])) {
|
||||||
|
parse_str($parsed['query'], $params);
|
||||||
|
ksort($params);
|
||||||
|
$full_url = $path . '?' . http_build_query($params);
|
||||||
|
} else {
|
||||||
|
$full_url = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = sha1($full_url);
|
||||||
|
$key = "fpc:{$build_key}:{$hash}";
|
||||||
|
|
||||||
|
return $redis->del($key) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Redis connection for FPC operations
|
||||||
|
* Uses DB 0 (cache DB with LRU eviction)
|
||||||
|
*/
|
||||||
|
private static function _get_redis(): ?\Redis
|
||||||
|
{
|
||||||
|
static $redis = null;
|
||||||
|
|
||||||
|
if ($redis) {
|
||||||
|
return $redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
$redis = new \Redis();
|
||||||
|
$host = env('REDIS_HOST', '127.0.0.1');
|
||||||
|
$port = (int) env('REDIS_PORT', 6379);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$redis->connect($host, $port, 2.0);
|
||||||
|
$redis->select(0);
|
||||||
|
return $redis;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$redis = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -222,6 +222,16 @@ class Rsx {
|
|||||||
return !window.rsxapp.debug;
|
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
|
* Get the current logged-in user model instance
|
||||||
* Returns the hydrated ORM model if available, or the raw data object
|
* Returns the hydrated ORM model if available, or the raw data object
|
||||||
@@ -717,24 +727,27 @@ class Rsx {
|
|||||||
Rsx.trigger(phase.event);
|
Rsx.trigger(phase.event);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ui refresh callbacks
|
|
||||||
Rsx.trigger_refresh();
|
|
||||||
|
|
||||||
// All phases complete
|
// All phases complete
|
||||||
console_debug('RSX_INIT', 'Initialization 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
|
// 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...
|
||||||
// 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
|
// Trigger _debug_ready event - this is ONLY for tooling like rsx:debug
|
||||||
// Use requestAnimationFrame to ensure DOM is fully rendered after SPA action completes
|
// DO NOT use this in application code - use on_app_ready() phase instead
|
||||||
requestAnimationFrame(() => {
|
// This event exists solely for debugging tools that need to run after full initialization
|
||||||
Rsx._restore_scroll_on_refresh();
|
Rsx.trigger('_debug_ready');
|
||||||
});
|
|
||||||
|
// Restore scroll position on page refresh
|
||||||
|
// Use requestAnimationFrame to ensure DOM is fully rendered after SPA action completes
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
Rsx._restore_scroll_on_refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
class Rsx_Behaviors {
|
class Rsx_Behaviors {
|
||||||
static _on_framework_core_init() {
|
static _on_framework_core_init() {
|
||||||
|
if (Rsx.is_ssr()) return;
|
||||||
Rsx_Behaviors._init_ignore_invalid_anchor_links();
|
Rsx_Behaviors._init_ignore_invalid_anchor_links();
|
||||||
Rsx_Behaviors._trim_copied_text();
|
Rsx_Behaviors._trim_copied_text();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
class Rsx_Droppable {
|
class Rsx_Droppable {
|
||||||
static _on_framework_core_init() {
|
static _on_framework_core_init() {
|
||||||
|
if (Rsx.is_ssr()) return;
|
||||||
Rsx_Droppable._init();
|
Rsx_Droppable._init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class Rsx_Init {
|
|||||||
* Initializes the core environment and runs basic sanity checks
|
* Initializes the core environment and runs basic sanity checks
|
||||||
*/
|
*/
|
||||||
static _on_framework_core_init() {
|
static _on_framework_core_init() {
|
||||||
|
if (Rsx.is_ssr()) return;
|
||||||
if (!Rsx.is_prod()) {
|
if (!Rsx.is_prod()) {
|
||||||
Rsx_Init.__environment_checks();
|
Rsx_Init.__environment_checks();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class Rsx_View_Transitions {
|
|||||||
* Checks for View Transitions API support and enables if available
|
* Checks for View Transitions API support and enables if available
|
||||||
*/
|
*/
|
||||||
static _on_framework_core_init() {
|
static _on_framework_core_init() {
|
||||||
|
if (Rsx.is_ssr()) return;
|
||||||
|
|
||||||
// Check if View Transitions API is supported
|
// Check if View Transitions API is supported
|
||||||
if (!document.startViewTransition) {
|
if (!document.startViewTransition) {
|
||||||
console_debug('VIEW_TRANSITIONS', 'View Transitions API not supported, skipping');
|
console_debug('VIEW_TRANSITIONS', 'View Transitions API not supported, skipping');
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ class Width_Group {
|
|||||||
* Initialize jQuery extensions
|
* Initialize jQuery extensions
|
||||||
*/
|
*/
|
||||||
static _on_framework_core_define() {
|
static _on_framework_core_define() {
|
||||||
|
if (Rsx.is_ssr()) return;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add elements to a named width group
|
* Add elements to a named width group
|
||||||
* @param {string} group_name - Name of the width group
|
* @param {string} group_name - Name of the width group
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class RsxLocks
|
|||||||
const LOCK_BUNDLE_BUILD = 'BUNDLE_BUILD'; // Bundle compilation operations
|
const LOCK_BUNDLE_BUILD = 'BUNDLE_BUILD'; // Bundle compilation operations
|
||||||
const LOCK_MIGRATION = 'MIGRATION'; // Database migration operations
|
const LOCK_MIGRATION = 'MIGRATION'; // Database migration operations
|
||||||
const LOCK_FILE_WRITE = 'FILE_WRITE'; // File upload/storage 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)
|
// Site-specific lock prefix (appended with site_id)
|
||||||
const LOCK_SITE_PREFIX = 'SITE_'; // e.g., SITE_1, SITE_2, etc.
|
const LOCK_SITE_PREFIX = 'SITE_'; // e.g., SITE_1, SITE_2, etc.
|
||||||
|
|||||||
@@ -146,6 +146,9 @@ class _Manifest_Cache_Helper
|
|||||||
}
|
}
|
||||||
|
|
||||||
file_put_contents($cache_file, $php_content);
|
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()
|
public static function _validate_cached_data()
|
||||||
@@ -364,6 +367,68 @@ class _Manifest_Cache_Helper
|
|||||||
"To: public static function {$method_name}(...)\n"
|
"To: public static function {$method_name}(...)\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check FPC attribute constraints
|
||||||
|
$has_fpc = false;
|
||||||
|
$has_spa = false;
|
||||||
|
|
||||||
|
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
|
||||||
|
if ($attr_name === 'FPC' || str_ends_with($attr_name, '\\FPC')) {
|
||||||
|
$has_fpc = true;
|
||||||
|
}
|
||||||
|
if ($attr_name === 'SPA' || str_ends_with($attr_name, '\\SPA')) {
|
||||||
|
$has_spa = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($has_fpc) {
|
||||||
|
$class_name = $metadata['class'] ?? 'Unknown';
|
||||||
|
|
||||||
|
if ($has_ajax_endpoint) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"#[FPC] cannot be used on Ajax endpoints\n" .
|
||||||
|
"Class: {$class_name}\n" .
|
||||||
|
"Method: {$method_name}\n" .
|
||||||
|
"File: {$file_path}\n\n" .
|
||||||
|
"#[FPC] marks a route for full page caching. Ajax endpoints return JSON\n" .
|
||||||
|
"data, not HTML pages.\n\n" .
|
||||||
|
"Solution: Remove #[FPC] from this Ajax endpoint."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($has_spa) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"#[FPC] cannot be used on #[SPA] methods\n" .
|
||||||
|
"Class: {$class_name}\n" .
|
||||||
|
"Method: {$method_name}\n" .
|
||||||
|
"File: {$file_path}\n\n" .
|
||||||
|
"SPA bootstrap methods return an empty shell that JavaScript populates.\n" .
|
||||||
|
"Caching this shell serves the same empty page for all SPA routes.\n\n" .
|
||||||
|
"Solution: Remove #[FPC] from this SPA method."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($has_task) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"#[FPC] cannot be used on Task methods\n" .
|
||||||
|
"Class: {$class_name}\n" .
|
||||||
|
"Method: {$method_name}\n" .
|
||||||
|
"File: {$file_path}\n\n" .
|
||||||
|
"Solution: Remove #[FPC] from this Task method."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$has_route) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"#[FPC] requires #[Route] attribute\n" .
|
||||||
|
"Class: {$class_name}\n" .
|
||||||
|
"Method: {$method_name}\n" .
|
||||||
|
"File: {$file_path}\n\n" .
|
||||||
|
"#[FPC] can only be used on methods with a #[Route] attribute.\n\n" .
|
||||||
|
"Solution: Add #[Route('/path')] to this method, or remove #[FPC]."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
398
app/RSpade/Core/SSR/Rsx_SSR.php
Executable file
398
app/RSpade/Core/SSR/Rsx_SSR.php
Executable file
@@ -0,0 +1,398 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Rsx_SSR - Server-side rendering of jqhtml components
|
||||||
|
*
|
||||||
|
* Manages the SSR Node server lifecycle and communicates via Unix socket.
|
||||||
|
* The server is started on-demand and stays running across requests. It is
|
||||||
|
* automatically restarted when the manifest build key changes (new code).
|
||||||
|
*
|
||||||
|
* All SSR operations are serialized via a Redis advisory lock to ensure
|
||||||
|
* only one PHP process manages the server lifecycle at a time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\RSpade\Core\SSR;
|
||||||
|
|
||||||
|
use App\RSpade\Core\Locks\RsxLocks;
|
||||||
|
use App\RSpade\Core\Manifest\Manifest;
|
||||||
|
|
||||||
|
class Rsx_SSR
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Unix socket path (relative to base_path)
|
||||||
|
*/
|
||||||
|
private const SOCKET_PATH = 'storage/rsx-tmp/ssr-server.sock';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build key sidecar file (relative to base_path)
|
||||||
|
*/
|
||||||
|
private const BUILD_KEY_FILE = 'storage/rsx-tmp/ssr-server.build_key';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PID file (relative to base_path)
|
||||||
|
*/
|
||||||
|
private const PID_FILE = 'storage/rsx-tmp/ssr-server.pid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSR server script (relative to base_path)
|
||||||
|
*/
|
||||||
|
private const SERVER_SCRIPT = 'bin/ssr-server.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default render timeout (seconds)
|
||||||
|
*/
|
||||||
|
private const DEFAULT_TIMEOUT = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server startup timeout (milliseconds)
|
||||||
|
*/
|
||||||
|
private const STARTUP_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server startup poll interval (milliseconds)
|
||||||
|
*/
|
||||||
|
private const STARTUP_POLL_MS = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC request ID counter
|
||||||
|
*/
|
||||||
|
private static int $request_id = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a jqhtml component to HTML via the SSR server
|
||||||
|
*
|
||||||
|
* Acquires a Redis advisory lock, ensures the server is running with
|
||||||
|
* current code, sends the render request, and returns the result.
|
||||||
|
*
|
||||||
|
* @param string $component Component class name (e.g., 'SSR_Test_Page')
|
||||||
|
* @param array $args Component arguments (becomes this.args)
|
||||||
|
* @param string $bundle_class Bundle class name (e.g., 'SSR_Test_Bundle')
|
||||||
|
* @return array ['html' => string, 'timing' => array, 'preload' => array]
|
||||||
|
* @throws \RuntimeException on connection or render failure
|
||||||
|
*/
|
||||||
|
public static function render_component(string $component, array $args = [], string $bundle_class = ''): array
|
||||||
|
{
|
||||||
|
$lock_token = RsxLocks::get_lock(
|
||||||
|
RsxLocks::SERVER_LOCK,
|
||||||
|
RsxLocks::LOCK_SSR_RENDER,
|
||||||
|
RsxLocks::WRITE_LOCK,
|
||||||
|
self::DEFAULT_TIMEOUT + 10
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure server is running with current build key
|
||||||
|
self::_ensure_server();
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
$bundle_paths = self::_get_bundle_paths($bundle_class);
|
||||||
|
|
||||||
|
$vendor_content = file_get_contents($bundle_paths['vendor_js']);
|
||||||
|
$app_content = file_get_contents($bundle_paths['app_js']);
|
||||||
|
|
||||||
|
if ($vendor_content === false) {
|
||||||
|
throw new \RuntimeException("SSR: Failed to read vendor bundle: {$bundle_paths['vendor_js']}");
|
||||||
|
}
|
||||||
|
if ($app_content === false) {
|
||||||
|
throw new \RuntimeException("SSR: Failed to read app bundle: {$bundle_paths['app_js']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = [
|
||||||
|
'id' => 'ssr-' . uniqid(),
|
||||||
|
'type' => 'render',
|
||||||
|
'payload' => [
|
||||||
|
'bundles' => [
|
||||||
|
['id' => 'vendor', 'content' => $vendor_content],
|
||||||
|
['id' => 'app', 'content' => $app_content],
|
||||||
|
],
|
||||||
|
'component' => $component,
|
||||||
|
'args' => empty($args) ? new \stdClass() : $args,
|
||||||
|
'options' => [
|
||||||
|
'baseUrl' => 'http://localhost',
|
||||||
|
'timeout' => self::DEFAULT_TIMEOUT * 1000,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = self::_send_request($request);
|
||||||
|
|
||||||
|
if ($response['status'] !== 'success') {
|
||||||
|
$error = $response['error'] ?? ['code' => 'UNKNOWN', 'message' => 'Unknown error'];
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"SSR render failed [{$error['code']}]: {$error['message']}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'html' => $response['payload']['html'],
|
||||||
|
'timing' => $response['payload']['timing'] ?? [],
|
||||||
|
'preload' => $response['payload']['preload'] ?? [],
|
||||||
|
];
|
||||||
|
} finally {
|
||||||
|
RsxLocks::release_lock($lock_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the SSR server is running with current code
|
||||||
|
*
|
||||||
|
* Checks socket existence and build key. Starts or restarts as needed.
|
||||||
|
*/
|
||||||
|
private static function _ensure_server(): void
|
||||||
|
{
|
||||||
|
$socket_path = base_path(self::SOCKET_PATH);
|
||||||
|
$build_key_file = base_path(self::BUILD_KEY_FILE);
|
||||||
|
$current_build_key = Manifest::get_build_key();
|
||||||
|
|
||||||
|
if (file_exists($socket_path)) {
|
||||||
|
// Check if server has current code
|
||||||
|
$stored_build_key = file_exists($build_key_file) ? trim(file_get_contents($build_key_file)) : '';
|
||||||
|
|
||||||
|
if ($stored_build_key === $current_build_key) {
|
||||||
|
// Build key matches — verify server is responsive
|
||||||
|
if (self::_ping_server()) {
|
||||||
|
return; // Server is running and current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build key mismatch or server unresponsive — restart
|
||||||
|
self::_stop_server(force: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start fresh server
|
||||||
|
self::_start_server();
|
||||||
|
|
||||||
|
// Record the build key
|
||||||
|
file_put_contents($build_key_file, $current_build_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the SSR Node server
|
||||||
|
*
|
||||||
|
* Spawns a daemonized Node process listening on the Unix socket.
|
||||||
|
* The process runs independently of PHP (survives FPM worker recycling).
|
||||||
|
* Polls until the server responds to ping, or throws on timeout.
|
||||||
|
*/
|
||||||
|
private static function _start_server(): void
|
||||||
|
{
|
||||||
|
$socket_path = base_path(self::SOCKET_PATH);
|
||||||
|
$pid_file = base_path(self::PID_FILE);
|
||||||
|
$server_script = base_path(self::SERVER_SCRIPT);
|
||||||
|
|
||||||
|
if (!file_exists($server_script)) {
|
||||||
|
throw new \RuntimeException("SSR server script not found at {$server_script}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure socket directory exists
|
||||||
|
$socket_dir = dirname($socket_path);
|
||||||
|
if (!is_dir($socket_dir)) {
|
||||||
|
mkdir($socket_dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up stale socket file
|
||||||
|
if (file_exists($socket_path)) {
|
||||||
|
@unlink($socket_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch as daemon — detached from PHP process tree
|
||||||
|
$cmd = sprintf(
|
||||||
|
'cd %s && nohup node %s --socket=%s > /dev/null 2>&1 & echo $!',
|
||||||
|
escapeshellarg(base_path()),
|
||||||
|
escapeshellarg($server_script),
|
||||||
|
escapeshellarg($socket_path)
|
||||||
|
);
|
||||||
|
|
||||||
|
$pid = (int) trim(shell_exec($cmd));
|
||||||
|
|
||||||
|
if ($pid > 0) {
|
||||||
|
file_put_contents($pid_file, (string) $pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for server readiness
|
||||||
|
$iterations = self::STARTUP_TIMEOUT_MS / self::STARTUP_POLL_MS;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $iterations; $i++) {
|
||||||
|
usleep(self::STARTUP_POLL_MS * 1000);
|
||||||
|
|
||||||
|
if (self::_ping_server()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"SSR server failed to start within " . self::STARTUP_TIMEOUT_MS . "ms"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the SSR server
|
||||||
|
*
|
||||||
|
* @param bool $force If true, forcefully kill after graceful attempt
|
||||||
|
*/
|
||||||
|
private static function _stop_server(bool $force = false): void
|
||||||
|
{
|
||||||
|
$socket_path = base_path(self::SOCKET_PATH);
|
||||||
|
$pid_file = base_path(self::PID_FILE);
|
||||||
|
|
||||||
|
if (file_exists($socket_path)) {
|
||||||
|
// Send shutdown command via socket
|
||||||
|
try {
|
||||||
|
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1);
|
||||||
|
if ($socket) {
|
||||||
|
stream_set_blocking($socket, true);
|
||||||
|
|
||||||
|
self::$request_id++;
|
||||||
|
$request = json_encode([
|
||||||
|
'id' => 'shutdown-' . self::$request_id,
|
||||||
|
'type' => 'shutdown',
|
||||||
|
]) . "\n";
|
||||||
|
|
||||||
|
fwrite($socket, $request);
|
||||||
|
fclose($socket);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Ignore errors during shutdown
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($force) {
|
||||||
|
usleep(100000); // 100ms grace period
|
||||||
|
@unlink($socket_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill process by PID if still running
|
||||||
|
if (file_exists($pid_file)) {
|
||||||
|
$pid = (int) trim(file_get_contents($pid_file));
|
||||||
|
if ($pid > 0 && posix_kill($pid, 0)) {
|
||||||
|
posix_kill($pid, SIGTERM);
|
||||||
|
}
|
||||||
|
@unlink($pid_file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping the SSR server to check if it's alive
|
||||||
|
*
|
||||||
|
* @return bool True if server responds, false otherwise
|
||||||
|
*/
|
||||||
|
private static function _ping_server(): bool
|
||||||
|
{
|
||||||
|
$socket_path = base_path(self::SOCKET_PATH);
|
||||||
|
|
||||||
|
if (!file_exists($socket_path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$socket = @stream_socket_client('unix://' . $socket_path, $errno, $errstr, 1);
|
||||||
|
if (!$socket) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream_set_blocking($socket, true);
|
||||||
|
stream_set_timeout($socket, 2);
|
||||||
|
|
||||||
|
self::$request_id++;
|
||||||
|
$request = json_encode([
|
||||||
|
'id' => 'ping-' . self::$request_id,
|
||||||
|
'type' => 'ping',
|
||||||
|
]) . "\n";
|
||||||
|
|
||||||
|
fwrite($socket, $request);
|
||||||
|
|
||||||
|
$response = fgets($socket);
|
||||||
|
fclose($socket);
|
||||||
|
|
||||||
|
if (!$response) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = json_decode($response, true);
|
||||||
|
return isset($result['status']) && $result['status'] === 'success';
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get compiled bundle file paths for a bundle class
|
||||||
|
*
|
||||||
|
* @param string $bundle_class Bundle class name
|
||||||
|
* @return array ['vendor_js' => string, 'app_js' => string]
|
||||||
|
* @throws \RuntimeException if bundle files not found
|
||||||
|
*/
|
||||||
|
private static function _get_bundle_paths(string $bundle_class): array
|
||||||
|
{
|
||||||
|
// Validate bundle class exists in manifest as a real bundle
|
||||||
|
if (!Manifest::php_is_subclass_of($bundle_class, \App\RSpade\Core\Bundle\Rsx_Bundle_Abstract::class)) {
|
||||||
|
throw new \RuntimeException("SSR: Invalid bundle class '{$bundle_class}' - not a registered bundle");
|
||||||
|
}
|
||||||
|
|
||||||
|
$build_dir = base_path('storage/rsx-build/bundles');
|
||||||
|
|
||||||
|
$vendor_files = glob("{$build_dir}/{$bundle_class}__vendor.*.js");
|
||||||
|
$app_files = glob("{$build_dir}/{$bundle_class}__app.*.js");
|
||||||
|
|
||||||
|
if (empty($vendor_files)) {
|
||||||
|
throw new \RuntimeException("SSR: Vendor bundle not found for {$bundle_class}. Run php artisan rsx:bundle:compile");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($app_files)) {
|
||||||
|
throw new \RuntimeException("SSR: App bundle not found for {$bundle_class}. Run php artisan rsx:bundle:compile");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'vendor_js' => $vendor_files[0],
|
||||||
|
'app_js' => $app_files[0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a request to the SSR server and return the response
|
||||||
|
*
|
||||||
|
* @param array $request Request data
|
||||||
|
* @return array Parsed response
|
||||||
|
* @throws \RuntimeException on connection or protocol failure
|
||||||
|
*/
|
||||||
|
private static function _send_request(array $request): array
|
||||||
|
{
|
||||||
|
$socket_path = base_path(self::SOCKET_PATH);
|
||||||
|
$timeout = self::DEFAULT_TIMEOUT + 5;
|
||||||
|
|
||||||
|
$socket = @stream_socket_client(
|
||||||
|
'unix://' . $socket_path,
|
||||||
|
$errno,
|
||||||
|
$errstr,
|
||||||
|
5
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$socket) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"SSR: Cannot connect to SSR server at {$socket_path} - {$errstr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stream_set_blocking($socket, true);
|
||||||
|
stream_set_timeout($socket, $timeout);
|
||||||
|
|
||||||
|
$request_json = json_encode($request) . "\n";
|
||||||
|
fwrite($socket, $request_json);
|
||||||
|
|
||||||
|
$response_line = fgets($socket);
|
||||||
|
$meta = stream_get_meta_data($socket);
|
||||||
|
fclose($socket);
|
||||||
|
|
||||||
|
if ($response_line === false) {
|
||||||
|
if (!empty($meta['timed_out'])) {
|
||||||
|
throw new \RuntimeException("SSR: Request timed out after {$timeout}s");
|
||||||
|
}
|
||||||
|
throw new \RuntimeException("SSR: Empty response from server");
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = json_decode(trim($response_line), true);
|
||||||
|
|
||||||
|
if ($response === null) {
|
||||||
|
throw new \RuntimeException("SSR: Invalid JSON response from server");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -616,7 +616,17 @@ class Session extends Rsx_System_Model_Abstract
|
|||||||
return;
|
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
|
// Skip if already set
|
||||||
if (self::get_site_id() === $site_id) {
|
if (self::get_site_id() === $site_id) {
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ class Jqhtml_Integration {
|
|||||||
* This runs during framework_modules_define, before any DOM processing.
|
* This runs during framework_modules_define, before any DOM processing.
|
||||||
*/
|
*/
|
||||||
static _on_framework_modules_define() {
|
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
|
// Register Component Classes with jqhtml Runtime
|
||||||
//
|
//
|
||||||
@@ -135,6 +148,12 @@ class Jqhtml_Integration {
|
|||||||
*/
|
*/
|
||||||
static async _on_framework_modules_init() {
|
static async _on_framework_modules_init() {
|
||||||
await jqhtml.boot();
|
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');
|
Rsx.trigger('jqhtml_ready');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ class Flash_Alert
|
|||||||
*/
|
*/
|
||||||
public static function get_pending_messages(): array
|
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();
|
$session_id = Session::get_session_id();
|
||||||
if ($session_id === null) {
|
if ($session_id === null) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
258
app/RSpade/man/fpc.txt
Executable file
258
app/RSpade/man/fpc.txt
Executable file
@@ -0,0 +1,258 @@
|
|||||||
|
FPC(3) RSX Framework Manual FPC(3)
|
||||||
|
|
||||||
|
NAME
|
||||||
|
FPC - Full Page Cache via Node.js reverse proxy with Redis
|
||||||
|
|
||||||
|
SYNOPSIS
|
||||||
|
Controller attribute:
|
||||||
|
|
||||||
|
#[FPC]
|
||||||
|
#[Route('/about', methods: ['GET'])]
|
||||||
|
public static function index(Request $request, array $params = [])
|
||||||
|
|
||||||
|
PHP cache management:
|
||||||
|
|
||||||
|
use App\RSpade\Core\FPC\Rsx_FPC;
|
||||||
|
|
||||||
|
Rsx_FPC::clear(); // Clear all FPC entries
|
||||||
|
Rsx_FPC::clear_url('/about'); // Clear specific URL
|
||||||
|
|
||||||
|
DESCRIPTION
|
||||||
|
The Full Page Cache (FPC) is a Node.js reverse proxy that sits between
|
||||||
|
nginx and PHP. Pages marked with #[FPC] are cached in Redis after first
|
||||||
|
render. Subsequent requests for the same URL are served directly from
|
||||||
|
Redis without touching PHP, with ETag validation for 304 responses.
|
||||||
|
|
||||||
|
Unlike traditional PHP caching (which still boots Laravel on every
|
||||||
|
request), the FPC proxy is a long-running Node.js process that serves
|
||||||
|
cached responses in under 1ms. PHP is only invoked on cache misses.
|
||||||
|
|
||||||
|
FPC is designed for public, anonymous pages like marketing sites,
|
||||||
|
documentation, and SSR-rendered content. Pages accessed by logged-in
|
||||||
|
users always bypass the cache.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
|
||||||
|
browser -> nginx ->
|
||||||
|
/_ajax/ -> ajax FPM pool (unchanged)
|
||||||
|
static files -> try_files (unchanged)
|
||||||
|
everything else -> FPC proxy (port 3200) ->
|
||||||
|
cache HIT -> Redis -> 304 or cached HTML
|
||||||
|
cache MISS -> nginx backend (port 3201) -> FPM ->
|
||||||
|
if X-RSpade-FPC header -> cache in Redis
|
||||||
|
|
||||||
|
ATTRIBUTE USAGE
|
||||||
|
Add #[FPC] to any controller method that should be cached:
|
||||||
|
|
||||||
|
#[FPC]
|
||||||
|
#[Route('/pricing', methods: ['GET'])]
|
||||||
|
public static function pricing(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
return rsx_view('Pricing_Page');
|
||||||
|
}
|
||||||
|
|
||||||
|
FPC Constraints:
|
||||||
|
- Requires #[Route] attribute (enforced by manifest validation)
|
||||||
|
- Cannot combine with #[Ajax_Endpoint]
|
||||||
|
- Cannot combine with #[SPA]
|
||||||
|
- Cannot combine with #[Task]
|
||||||
|
|
||||||
|
The #[FPC] attribute only affects GET requests from anonymous visitors.
|
||||||
|
For logged-in users, POST requests, or non-GET methods, the request
|
||||||
|
passes through to PHP normally with no caching.
|
||||||
|
|
||||||
|
CACHE BEHAVIOR
|
||||||
|
Cache Key:
|
||||||
|
fpc:{build_key}:{sha1(path?sorted_query_params)}
|
||||||
|
|
||||||
|
The build key is the manifest hash, written to
|
||||||
|
system/storage/rsx-build/build_key during manifest save. When any file
|
||||||
|
changes trigger a manifest rebuild, the build key changes and all FPC
|
||||||
|
entries are effectively invalidated (new cache keys, old entries expire
|
||||||
|
via Redis LRU).
|
||||||
|
|
||||||
|
Cache Entry (stored as JSON in Redis):
|
||||||
|
url - Request path
|
||||||
|
html - Full HTML response body
|
||||||
|
status_code - HTTP status (200, 301, 302, etc.)
|
||||||
|
content_type - Response Content-Type header
|
||||||
|
location - Redirect target (for 3xx responses)
|
||||||
|
etag - SHA1 hash of body (first 30 chars)
|
||||||
|
cached_at - ISO timestamp of cache time
|
||||||
|
|
||||||
|
ETag Validation:
|
||||||
|
Client sends If-None-Match header with cached ETag.
|
||||||
|
If ETag matches cached entry, proxy returns 304 Not Modified
|
||||||
|
with no body. This saves bandwidth on repeat visits.
|
||||||
|
|
||||||
|
Session Cookie Bypass:
|
||||||
|
If the request contains an 'rsx' cookie (indicating the visitor
|
||||||
|
has an active session), the proxy passes through directly to PHP.
|
||||||
|
Session users never receive cached content.
|
||||||
|
|
||||||
|
POST/Non-GET Bypass:
|
||||||
|
Non-GET/HEAD methods always pass through to PHP.
|
||||||
|
HEAD requests can READ from cache but never POPULATE it.
|
||||||
|
|
||||||
|
RESPONSE HEADERS
|
||||||
|
X-FPC-Cache: HIT - Response served from Redis cache
|
||||||
|
X-FPC-Cache: MISS - Response served from PHP (and cached for next time)
|
||||||
|
ETag: {hash} - Content hash for 304 validation
|
||||||
|
|
||||||
|
The internal X-RSpade-FPC header (set by PHP to signal cacheability)
|
||||||
|
is stripped by the proxy and never sent to clients.
|
||||||
|
|
||||||
|
PHP CACHE MANAGEMENT
|
||||||
|
Rsx_FPC::clear()
|
||||||
|
Clear all FPC entries for the current build key.
|
||||||
|
Uses SCAN (not KEYS) to avoid blocking Redis.
|
||||||
|
Returns count of deleted entries.
|
||||||
|
|
||||||
|
Rsx_FPC::clear_url(string $url)
|
||||||
|
Clear FPC entry for a specific URL path.
|
||||||
|
Handles query parameter sorting to match proxy key generation.
|
||||||
|
Returns true if entry was deleted.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
// Clear all cached pages after content update
|
||||||
|
Rsx_FPC::clear();
|
||||||
|
|
||||||
|
// Clear specific page after editing its content
|
||||||
|
Rsx_FPC::clear_url('/about');
|
||||||
|
Rsx_FPC::clear_url('/search?q=test&page=1');
|
||||||
|
|
||||||
|
CONFIGURATION
|
||||||
|
Environment variables (in .env):
|
||||||
|
|
||||||
|
FPC_PROXY_PORT Port for FPC proxy (default: 3200)
|
||||||
|
FPC_BACKEND_PORT Port for nginx backend (default: 3201)
|
||||||
|
REDIS_HOST Redis server host (default: 127.0.0.1)
|
||||||
|
REDIS_PORT Redis server port (default: 6379)
|
||||||
|
REDIS_PASSWORD Redis password (default: none)
|
||||||
|
|
||||||
|
Redis uses DB 0 (default database with LRU eviction policy).
|
||||||
|
|
||||||
|
NGINX SETUP
|
||||||
|
The FPC requires two nginx changes:
|
||||||
|
|
||||||
|
1. Backend server block (accessed by proxy for cache misses):
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 127.0.0.1:3201;
|
||||||
|
root /var/www/html/system/public;
|
||||||
|
|
||||||
|
location /_ajax/ {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
|
||||||
|
fastcgi_param SCRIPT_NAME /index.php;
|
||||||
|
fastcgi_pass unix:/run/php/php-fpm-ajax.sock;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php?$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
include snippets/fastcgi-php.conf;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
fastcgi_pass unix:/run/php/php-fpm.sock;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
2. Main server blocks - replace location / with proxy fallback:
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ @fpc_proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @fpc_proxy {
|
||||||
|
proxy_pass http://127.0.0.1:3200;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
SUPERVISOR
|
||||||
|
The FPC proxy runs as a supervised service:
|
||||||
|
|
||||||
|
/system/supervisor/fpc-proxy.conf
|
||||||
|
|
||||||
|
Start manually for testing:
|
||||||
|
|
||||||
|
node system/bin/fpc-proxy.js
|
||||||
|
|
||||||
|
DISPATCHER BEHAVIOR
|
||||||
|
When the Dispatcher matches a route with #[FPC]:
|
||||||
|
|
||||||
|
1. Checks request is GET with no POST/FILES data
|
||||||
|
2. Calls Session::init() to load existing session (does NOT create one)
|
||||||
|
3. If user is logged in, skips FPC (no header set)
|
||||||
|
4. If anonymous, blanks all cookies except 'rsx' from $_COOKIE
|
||||||
|
5. Executes controller method normally
|
||||||
|
6. Adds X-RSpade-FPC: 1 header to response
|
||||||
|
7. Removes Set-Cookie headers from response
|
||||||
|
|
||||||
|
Cookie blanking (step 4) ensures controller code produces identical
|
||||||
|
output for all anonymous visitors regardless of any tracking cookies.
|
||||||
|
|
||||||
|
SESSION COOKIE SAFETY
|
||||||
|
RSpade does NOT create session cookies for anonymous visitors.
|
||||||
|
Session::init() only reads existing cookies, never creates new ones.
|
||||||
|
Session::set_site_id() stores site_id as a request-scoped override
|
||||||
|
for anonymous visitors without creating a database session.
|
||||||
|
|
||||||
|
Sessions are only created when code explicitly calls:
|
||||||
|
- Session::get_session_id()
|
||||||
|
- Session::get_session()
|
||||||
|
- Session::set_login_user_id()
|
||||||
|
|
||||||
|
This ensures FPC-cacheable pages never produce Set-Cookie headers
|
||||||
|
that would prevent caching.
|
||||||
|
|
||||||
|
DEBUGGING
|
||||||
|
Check FPC cache status:
|
||||||
|
|
||||||
|
curl -sI http://localhost:3200/your-page | grep X-FPC
|
||||||
|
|
||||||
|
View cached entry:
|
||||||
|
|
||||||
|
redis-cli GET "fpc:{build_key}:{sha1_of_url}"
|
||||||
|
|
||||||
|
List all FPC keys:
|
||||||
|
|
||||||
|
redis-cli KEYS "fpc:*"
|
||||||
|
|
||||||
|
Clear all FPC entries:
|
||||||
|
|
||||||
|
redis-cli KEYS "fpc:*" | xargs -r redis-cli DEL
|
||||||
|
|
||||||
|
Check proxy logs:
|
||||||
|
|
||||||
|
/var/log/supervisor/fpc-proxy.log
|
||||||
|
/var/log/supervisor/fpc-proxy-error.log
|
||||||
|
|
||||||
|
TESTING
|
||||||
|
Integration tests in /system/app/RSpade/tests/fpc/:
|
||||||
|
|
||||||
|
01_session_cookie_behavior - Verifies no cookies for anonymous
|
||||||
|
02_fpc_cache_behavior - Cache MISS/HIT, ETag 304, bypasses
|
||||||
|
|
||||||
|
Run tests:
|
||||||
|
|
||||||
|
./system/app/RSpade/tests/fpc/01_session_cookie_behavior/run_test.sh
|
||||||
|
./system/app/RSpade/tests/fpc/02_fpc_cache_behavior/run_test.sh
|
||||||
|
|
||||||
|
FILES
|
||||||
|
system/bin/fpc-proxy.js Node.js proxy server
|
||||||
|
system/app/RSpade/Core/FPC/Rsx_FPC.php PHP cache utility
|
||||||
|
system/supervisor/fpc-proxy.conf Supervisor config
|
||||||
|
system/storage/rsx-build/build_key Build key file
|
||||||
|
system/app/RSpade/tests/fpc/ Integration tests
|
||||||
|
|
||||||
|
SEE ALSO
|
||||||
|
ssr(3) - Server-side rendering (produces HTML that FPC caches)
|
||||||
|
session(3) - Session management and cookie behavior
|
||||||
204
app/RSpade/man/ssr.txt
Executable file
204
app/RSpade/man/ssr.txt
Executable file
@@ -0,0 +1,204 @@
|
|||||||
|
SSR(3) RSX Framework Manual SSR(3)
|
||||||
|
|
||||||
|
NAME
|
||||||
|
SSR - Server-side rendering of jqhtml components via Node.js
|
||||||
|
|
||||||
|
SYNOPSIS
|
||||||
|
use App\RSpade\Core\SSR\Rsx_SSR;
|
||||||
|
|
||||||
|
$result = Rsx_SSR::render_component('Component_Name', $args, 'Bundle_Class');
|
||||||
|
// $result = ['html' => '...', 'timing' => [...], 'preload' => [...]]
|
||||||
|
|
||||||
|
DESCRIPTION
|
||||||
|
Rsx_SSR renders jqhtml components to HTML strings on the server using a
|
||||||
|
long-running Node.js process. The rendered HTML is injected into a Blade
|
||||||
|
template so the browser receives fully-formed markup without waiting for
|
||||||
|
JavaScript execution.
|
||||||
|
|
||||||
|
Primary use case: search engine crawlers and social media link previews.
|
||||||
|
Crawlers that don't execute JavaScript see empty SPA shells. SSR provides
|
||||||
|
real HTML content for indexing while the same components work as normal
|
||||||
|
client-side jqhtml components for regular users.
|
||||||
|
|
||||||
|
SSR pages are for unauthenticated users only. The SSR Node environment
|
||||||
|
has no session, no cookies, and no user context. Authenticated users
|
||||||
|
should be served the normal SPA/CSR version of the page.
|
||||||
|
|
||||||
|
HOW IT WORKS
|
||||||
|
1. PHP controller calls Rsx_SSR::render_component()
|
||||||
|
2. PHP acquires a Redis advisory lock (LOCK_SSR_RENDER) to serialize
|
||||||
|
SSR operations across concurrent requests
|
||||||
|
3. PHP ensures the SSR Node server is running (auto-starts if needed)
|
||||||
|
4. PHP reads the compiled bundle files and sends them + the component
|
||||||
|
name to the Node server over a Unix socket
|
||||||
|
5. Node creates an isolated jsdom environment, executes the bundles,
|
||||||
|
runs the full RSpade 10-phase boot lifecycle (with SSR guards on
|
||||||
|
browser-only classes), then renders the component
|
||||||
|
6. The component's on_load() runs normally, making Ajax calls back to
|
||||||
|
PHP to fetch data (e.g., Controller.get_page_data())
|
||||||
|
7. Node returns the rendered HTML and any preload data
|
||||||
|
8. PHP injects the HTML into a Blade template and optionally seeds
|
||||||
|
preload data via PageData for client-side hydration
|
||||||
|
|
||||||
|
The Node server stays running across requests and is automatically
|
||||||
|
restarted when the manifest build key changes (indicating code changes).
|
||||||
|
|
||||||
|
CONTROLLER USAGE
|
||||||
|
A typical SSR controller has three parts: the SSR route, a CSR fallback
|
||||||
|
route, and a public Ajax endpoint for data.
|
||||||
|
|
||||||
|
1. SSR Route (returns pre-rendered HTML):
|
||||||
|
|
||||||
|
use App\RSpade\Core\SSR\Rsx_SSR;
|
||||||
|
use App\RSpade\Core\View\PageData;
|
||||||
|
|
||||||
|
#[Route('/pricing', methods: ['GET'])]
|
||||||
|
public static function index(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
$result = Rsx_SSR::render_component(
|
||||||
|
'Pricing_Page', // Component class name
|
||||||
|
[], // Component args (becomes this.args)
|
||||||
|
'Marketing_Bundle' // Bundle containing the component
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed preload data so client-side hydration skips on_load()
|
||||||
|
if (!empty($result['preload'])) {
|
||||||
|
PageData::add([
|
||||||
|
'ssr' => true,
|
||||||
|
'ssr_preload' => $result['preload'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rsx_view('Pricing_Index', [
|
||||||
|
'html' => $result['html'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
2. Blade Template (injects SSR HTML into the page):
|
||||||
|
|
||||||
|
The Blade template outputs {!! $html !!} inside the container where
|
||||||
|
the component would normally render client-side.
|
||||||
|
|
||||||
|
3. Public Ajax Endpoint (serves data to both SSR and CSR):
|
||||||
|
|
||||||
|
#[Ajax_Endpoint]
|
||||||
|
public static function get_page_data(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'plans' => Plan_Model::where('active', true)->get()->toArray(),
|
||||||
|
'features' => Feature_Model::all()->toArray(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
The component's on_load() calls this endpoint identically whether
|
||||||
|
running server-side in Node or client-side in the browser:
|
||||||
|
|
||||||
|
async on_load() {
|
||||||
|
const data = await Marketing_Controller.get_page_data();
|
||||||
|
this.data.plans = data.plans;
|
||||||
|
this.data.features = data.features;
|
||||||
|
}
|
||||||
|
|
||||||
|
COMPONENT RESTRICTIONS
|
||||||
|
Components rendered via SSR must follow these rules:
|
||||||
|
|
||||||
|
- Data loading goes in on_load(), not on_create()
|
||||||
|
on_create() should only set empty defaults (this.data.items = []).
|
||||||
|
on_load() fetches data via Ajax endpoints. This is required because
|
||||||
|
SSR captures the data returned by on_load() for the preload cache.
|
||||||
|
|
||||||
|
- Ajax endpoints must be public (@auth-exempt on the controller)
|
||||||
|
The SSR Node server has no session. Ajax calls from SSR go through
|
||||||
|
PHP as unauthenticated requests.
|
||||||
|
|
||||||
|
- No browser-only APIs in on_create() or on_load()
|
||||||
|
window.innerWidth, navigator, localStorage (the real ones), etc.
|
||||||
|
are not available. on_ready() is fine since it only runs client-side.
|
||||||
|
|
||||||
|
- No user-specific content
|
||||||
|
SSR pages are cached and served to all unauthenticated users.
|
||||||
|
User-specific content belongs in authenticated SPA routes.
|
||||||
|
|
||||||
|
SERVER LIFECYCLE
|
||||||
|
The SSR Node server is managed automatically by Rsx_SSR:
|
||||||
|
|
||||||
|
- Auto-start: First render_component() call starts the server
|
||||||
|
- Persistence: Server stays running across PHP requests
|
||||||
|
- Auto-restart: Server restarts when manifest build key changes
|
||||||
|
- Concurrency: Redis advisory lock serializes SSR operations
|
||||||
|
- Cleanup: Server is killed by PID when restarting
|
||||||
|
|
||||||
|
Files:
|
||||||
|
storage/rsx-tmp/ssr-server.sock Unix socket
|
||||||
|
storage/rsx-tmp/ssr-server.pid Process ID
|
||||||
|
storage/rsx-tmp/ssr-server.build_key Last known build key
|
||||||
|
|
||||||
|
The server process is daemonized (detached from PHP). It survives
|
||||||
|
PHP-FPM worker recycling and terminal disconnects.
|
||||||
|
|
||||||
|
NGINX AND PHP-FPM DEADLOCK PREVENTION
|
||||||
|
SSR components that call Ajax endpoints create a callback loop:
|
||||||
|
|
||||||
|
Browser -> nginx -> PHP-FPM (page request)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Node SSR server
|
||||||
|
|
|
||||||
|
v (Ajax callback)
|
||||||
|
nginx -> PHP-FPM (ajax request)
|
||||||
|
|
||||||
|
If both the page request and the Ajax callback compete for the same
|
||||||
|
PHP-FPM worker pool, a deadlock occurs when all workers are busy
|
||||||
|
handling SSR page requests, leaving no workers for Ajax callbacks.
|
||||||
|
|
||||||
|
Solution: Route Ajax requests to a separate PHP-FPM pool.
|
||||||
|
|
||||||
|
PHP-FPM Configuration:
|
||||||
|
|
||||||
|
/etc/php/8.4/fpm/pool.d/www.conf (page requests):
|
||||||
|
[www]
|
||||||
|
listen = /run/php/php-fpm.sock
|
||||||
|
pm = ondemand
|
||||||
|
pm.max_children = 4
|
||||||
|
|
||||||
|
/etc/php/8.4/fpm/pool.d/ajax.conf (Ajax requests):
|
||||||
|
[ajax]
|
||||||
|
listen = /run/php/php-fpm-ajax.sock
|
||||||
|
pm = ondemand
|
||||||
|
pm.max_children = 4
|
||||||
|
|
||||||
|
Nginx Configuration (add to each server block):
|
||||||
|
|
||||||
|
# Ajax requests use separate FPM pool (prevents SSR deadlock)
|
||||||
|
location /_ajax/ {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
|
||||||
|
fastcgi_param SCRIPT_NAME /index.php;
|
||||||
|
fastcgi_pass unix:/run/php/php-fpm-ajax.sock;
|
||||||
|
}
|
||||||
|
|
||||||
|
For HTTPS server blocks, also add:
|
||||||
|
fastcgi_param HTTPS 'on';
|
||||||
|
|
||||||
|
This ensures SSR Ajax callbacks always have available workers,
|
||||||
|
regardless of how many page requests are in-flight.
|
||||||
|
|
||||||
|
PERFORMANCE
|
||||||
|
First request after server start: ~800-1000ms (server startup + render)
|
||||||
|
Subsequent requests (server warm): ~150-300ms (render only)
|
||||||
|
Bundle code is cached in-memory by the Node server across requests.
|
||||||
|
|
||||||
|
For high-traffic SSR pages, combine with the Full Page Cache system
|
||||||
|
(see rsx:man ssr_fpc) to serve pre-rendered HTML from Redis without
|
||||||
|
invoking the SSR server at all.
|
||||||
|
|
||||||
|
SEE ALSO
|
||||||
|
rsx:man ssr_fpc Full Page Cache for static SSR pages
|
||||||
|
rsx:man pagedata Server-to-client data passing
|
||||||
|
rsx:man jqhtml Component system
|
||||||
|
rsx:man ajax_error_handling Ajax endpoint patterns
|
||||||
|
|
||||||
|
AUTHOR
|
||||||
|
RSpade Framework
|
||||||
|
|
||||||
|
RSpade March 2026 SSR(3)
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
================================================================================
|
|
||||||
SSR FULL PAGE CACHE (FPC)
|
|
||||||
================================================================================
|
|
||||||
|
|
||||||
OVERVIEW
|
|
||||||
|
|
||||||
The SSR (Server-Side Rendered) Full Page Cache system pre-renders static pages
|
|
||||||
via headless Chrome (Playwright) and caches the resulting HTML in Redis for
|
|
||||||
optimal SEO and performance. Routes marked with #[Static_Page] attribute are
|
|
||||||
automatically cached and served to unauthenticated users.
|
|
||||||
|
|
||||||
This system is ideal for:
|
|
||||||
- Public-facing pages (landing pages, about, blog posts, documentation)
|
|
||||||
- Content that changes infrequently but is accessed frequently
|
|
||||||
- Pages requiring optimal SEO (search engines get pre-rendered HTML)
|
|
||||||
- Reducing server load for high-traffic public pages
|
|
||||||
|
|
||||||
|
|
||||||
CORE CONCEPTS
|
|
||||||
|
|
||||||
1. STATIC PAGE MARKING
|
|
||||||
Routes opt-in to FPC by adding the #[Static_Page] attribute to their
|
|
||||||
controller method. Only routes with this attribute are eligible for caching.
|
|
||||||
|
|
||||||
2. AUTHENTICATION-AWARE CACHING
|
|
||||||
FPC only serves cached content to unauthenticated users (no active session).
|
|
||||||
Logged-in users always get dynamic content, ensuring personalized experiences.
|
|
||||||
|
|
||||||
3. BUILD-KEY AUTO-INVALIDATION
|
|
||||||
Cache keys incorporate the application's build_key, ensuring all cached
|
|
||||||
pages are automatically invalidated on deployment. No manual cache clearing
|
|
||||||
needed during deployments.
|
|
||||||
|
|
||||||
4. ETAG VALIDATION
|
|
||||||
Each cached page includes a 30-character ETag for HTTP cache validation.
|
|
||||||
Browsers can send If-None-Match headers to receive 304 Not Modified
|
|
||||||
responses, reducing bandwidth usage.
|
|
||||||
|
|
||||||
5. REDIRECT CACHING
|
|
||||||
If a route redirects (300-399 status codes), the FPC system caches the
|
|
||||||
first redirect response without following it, preserving redirect behavior.
|
|
||||||
|
|
||||||
6. QUERY PARAMETER STRIPPING
|
|
||||||
GET parameters are stripped from URLs before cache key generation, ensuring
|
|
||||||
/about and /about?utm_source=email serve the same cached content.
|
|
||||||
|
|
||||||
|
|
||||||
ATTRIBUTE SYNTAX
|
|
||||||
|
|
||||||
Add the #[Static_Page] attribute to any route method:
|
|
||||||
|
|
||||||
#[Static_Page]
|
|
||||||
#[Route('/about')]
|
|
||||||
public static function about(Request $request, array $params = [])
|
|
||||||
{
|
|
||||||
return view('frontend.about');
|
|
||||||
}
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
- Route must be GET only (POST, PUT, DELETE not supported)
|
|
||||||
- Route must not require authentication (served to unauthenticated users only)
|
|
||||||
- Route should produce consistent output regardless of GET parameters
|
|
||||||
|
|
||||||
|
|
||||||
CACHE LIFECYCLE
|
|
||||||
|
|
||||||
1. ROUTE DISCOVERY
|
|
||||||
During request dispatch, the Dispatcher checks if the matched route has
|
|
||||||
the #[Static_Page] attribute.
|
|
||||||
|
|
||||||
2. SESSION CHECK
|
|
||||||
If the user has an active session, FPC is bypassed and the route executes
|
|
||||||
normally to provide personalized content.
|
|
||||||
|
|
||||||
3. CACHE LOOKUP
|
|
||||||
For unauthenticated users, the Dispatcher queries Redis for a cached version
|
|
||||||
using the key format: ssr_fpc:{build_key}:{sha1(url)}
|
|
||||||
|
|
||||||
4. CACHE GENERATION (ON MISS)
|
|
||||||
If no cache exists, the system automatically runs:
|
|
||||||
php artisan rsx:ssr_fpc:create /route
|
|
||||||
|
|
||||||
This command:
|
|
||||||
- Launches headless Chrome via Playwright
|
|
||||||
- Sets X-RSpade-FPC-Client header to prevent infinite loops
|
|
||||||
- Navigates to the route and waits for _debug_ready event
|
|
||||||
- Captures the fully rendered DOM or redirect response
|
|
||||||
- Stores the result in Redis with metadata (ETag, build_key, etc.)
|
|
||||||
|
|
||||||
5. CACHE SERVING
|
|
||||||
The cached HTML is served with appropriate headers:
|
|
||||||
- ETag: {30-char hash} for cache validation
|
|
||||||
- Cache-Control: public, max-age=300 (5 min in production)
|
|
||||||
- Cache-Control: no-cache, must-revalidate (0s in development)
|
|
||||||
|
|
||||||
6. CACHE INVALIDATION
|
|
||||||
Caches are automatically invalidated when:
|
|
||||||
- Application is deployed (build_key changes)
|
|
||||||
- Redis LRU eviction policy removes old entries
|
|
||||||
- Manual reset via rsx:ssr_fpc:reset command
|
|
||||||
|
|
||||||
|
|
||||||
COMMANDS
|
|
||||||
|
|
||||||
php artisan rsx:ssr_fpc:create <url>
|
|
||||||
Generate static cache for a specific URL.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
url The URL path to cache (e.g., /about)
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- Only available in non-production environments
|
|
||||||
- Requires rsx.ssr_fpc.enabled = true
|
|
||||||
- Strips GET parameters from URL
|
|
||||||
- Launches Playwright to render page
|
|
||||||
- Stores result in Redis
|
|
||||||
- Runs under GENERATE_STATIC_CACHE exclusive lock
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
php artisan rsx:ssr_fpc:create /
|
|
||||||
php artisan rsx:ssr_fpc:create /about
|
|
||||||
php artisan rsx:ssr_fpc:create /blog/post-title
|
|
||||||
|
|
||||||
php artisan rsx:ssr_fpc:reset
|
|
||||||
Clear all SSR FPC cache entries from Redis.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- Available in all environments (local, staging, production)
|
|
||||||
- Safe to run - only affects ssr_fpc:* keys
|
|
||||||
- Does NOT affect application caches, sessions, or queue jobs
|
|
||||||
- Reports count of cleared cache entries
|
|
||||||
|
|
||||||
When to use:
|
|
||||||
- After major content updates across the site
|
|
||||||
- When troubleshooting caching issues
|
|
||||||
- Before deployment to ensure fresh cache generation (optional)
|
|
||||||
- When build_key changes (auto-invalidation should handle this)
|
|
||||||
|
|
||||||
|
|
||||||
CONFIGURATION
|
|
||||||
|
|
||||||
Configuration is stored in config/rsx.php under the 'ssr_fpc' key:
|
|
||||||
|
|
||||||
'ssr_fpc' => [
|
|
||||||
// Enable SSR Full Page Cache system
|
|
||||||
'enabled' => env('SSR_FPC_ENABLED', false),
|
|
||||||
|
|
||||||
// Playwright generation timeout in milliseconds
|
|
||||||
'generation_timeout' => env('SSR_FPC_TIMEOUT', 30000),
|
|
||||||
],
|
|
||||||
|
|
||||||
Environment Variables:
|
|
||||||
SSR_FPC_ENABLED Enable/disable the FPC system (default: false)
|
|
||||||
SSR_FPC_TIMEOUT Page generation timeout in ms (default: 30000)
|
|
||||||
|
|
||||||
To enable FPC, add to .env:
|
|
||||||
SSR_FPC_ENABLED=true
|
|
||||||
|
|
||||||
|
|
||||||
REDIS CACHE STRUCTURE
|
|
||||||
|
|
||||||
Cache Key Format:
|
|
||||||
ssr_fpc:{build_key}:{sha1(url)}
|
|
||||||
|
|
||||||
Example:
|
|
||||||
ssr_fpc:abc123def456:5d41402abc4b2a76b9719d911017c592
|
|
||||||
|
|
||||||
Cache Value (JSON):
|
|
||||||
{
|
|
||||||
"url": "/about",
|
|
||||||
"code": 200,
|
|
||||||
"page_dom": "<!DOCTYPE html>...",
|
|
||||||
"redirect": null,
|
|
||||||
"build_key": "abc123def456",
|
|
||||||
"etag": "abc123def456789012345678901234",
|
|
||||||
"generated_at": "2025-10-17 14:32:10"
|
|
||||||
}
|
|
||||||
|
|
||||||
For redirect responses:
|
|
||||||
{
|
|
||||||
"url": "/old-page",
|
|
||||||
"code": 301,
|
|
||||||
"page_dom": null,
|
|
||||||
"redirect": "/new-page",
|
|
||||||
"build_key": "abc123def456",
|
|
||||||
"etag": "abc123def456789012345678901234",
|
|
||||||
"generated_at": "2025-10-17 14:32:10"
|
|
||||||
}
|
|
||||||
|
|
||||||
Cache Eviction:
|
|
||||||
Redis LRU (Least Recently Used) policy automatically removes old entries
|
|
||||||
when memory limits are reached. No manual TTL management required.
|
|
||||||
|
|
||||||
|
|
||||||
SECURITY CONSIDERATIONS
|
|
||||||
|
|
||||||
1. INFINITE LOOP PREVENTION
|
|
||||||
The Playwright script sets X-RSpade-FPC-Client: 1 header to identify itself
|
|
||||||
as a cache generation request. Session::__is_fpc_client() checks this header
|
|
||||||
to prevent FPC from serving cached content to the generation process.
|
|
||||||
|
|
||||||
2. PRODUCTION COMMAND BLOCKING
|
|
||||||
The rsx:ssr_fpc:create command throws a fatal error in production to prevent
|
|
||||||
accidental cache generation in production environments. Cache generation
|
|
||||||
should happen automatically on first request or via staging/local environments.
|
|
||||||
|
|
||||||
3. AUTHENTICATION BYPASS
|
|
||||||
FPC only serves to unauthenticated users. Auth checks still run for logged-in
|
|
||||||
users, ensuring secure routes remain protected even if accidentally marked
|
|
||||||
with #[Static_Page].
|
|
||||||
|
|
||||||
4. QUERY PARAMETER STRIPPING
|
|
||||||
GET parameters are stripped before caching to prevent cache poisoning via
|
|
||||||
malicious query strings. Routes that depend on GET parameters should NOT
|
|
||||||
use #[Static_Page].
|
|
||||||
|
|
||||||
|
|
||||||
PERFORMANCE CHARACTERISTICS
|
|
||||||
|
|
||||||
Cache Hit (Unauthenticated User):
|
|
||||||
- Served directly from Redis before route execution
|
|
||||||
- No PHP controller execution
|
|
||||||
- No database queries
|
|
||||||
- Minimal memory usage
|
|
||||||
- Response time: ~1-5ms
|
|
||||||
|
|
||||||
Cache Miss (First Request):
|
|
||||||
- Playwright launches headless Chrome
|
|
||||||
- Page renders fully (waits for _debug_ready + network idle)
|
|
||||||
- DOM captured and stored in Redis
|
|
||||||
- Response time: ~500-2000ms (one-time cost)
|
|
||||||
|
|
||||||
Authenticated User:
|
|
||||||
- FPC bypassed completely
|
|
||||||
- Normal route execution
|
|
||||||
- No performance impact
|
|
||||||
|
|
||||||
|
|
||||||
DEBUGGING AND TROUBLESHOOTING
|
|
||||||
|
|
||||||
Enable console_debug output for FPC:
|
|
||||||
CONSOLE_DEBUG_FILTER=SSR_FPC php artisan rsx:debug /about
|
|
||||||
|
|
||||||
Common issues:
|
|
||||||
|
|
||||||
1. Cache not being served
|
|
||||||
- Check: rsx.ssr_fpc.enabled = true
|
|
||||||
- Check: Route has #[Static_Page] attribute
|
|
||||||
- Check: User is unauthenticated (no active session)
|
|
||||||
- Check: Redis is running and accessible
|
|
||||||
|
|
||||||
2. Cache generation fails
|
|
||||||
- Check: Playwright is installed (npm install)
|
|
||||||
- Check: Timeout setting (increase SSR_FPC_TIMEOUT)
|
|
||||||
- Check: Route returns valid HTML (not JSON/PDF/etc.)
|
|
||||||
- Check: Route doesn't redirect to external domains
|
|
||||||
|
|
||||||
3. Stale cache after deployment
|
|
||||||
- Cache should auto-invalidate (build_key changed)
|
|
||||||
- Manual reset: php artisan rsx:ssr_fpc:reset
|
|
||||||
- Check: build_key is being regenerated on deployment
|
|
||||||
|
|
||||||
4. 304 responses not working
|
|
||||||
- Check: Browser sends If-None-Match header
|
|
||||||
- Check: ETag matches exactly (case-sensitive)
|
|
||||||
- Check: Response includes ETag header
|
|
||||||
|
|
||||||
|
|
||||||
FUTURE ROADMAP
|
|
||||||
|
|
||||||
Planned enhancements:
|
|
||||||
|
|
||||||
1. SITEMAP INTEGRATION
|
|
||||||
Automatically generate static caches for all routes in sitemap.xml during
|
|
||||||
deployment. Ensures all public pages are pre-cached before first user visit.
|
|
||||||
|
|
||||||
2. PARALLEL GENERATION
|
|
||||||
Generate multiple static caches concurrently using a worker pool, reducing
|
|
||||||
total cache generation time for large sites.
|
|
||||||
|
|
||||||
3. SHARED SECRET KEYS
|
|
||||||
Support deployment across multiple servers with shared cache keys, enabling
|
|
||||||
cache pre-generation on staging before production deployment.
|
|
||||||
|
|
||||||
4. EXTERNAL CACHE SERVICE
|
|
||||||
Move cache generation to a separate service (e.g., AWS Lambda, dedicated
|
|
||||||
worker) to reduce load on application servers during cache regeneration.
|
|
||||||
|
|
||||||
5. PROGRAMMATIC CACHE RESET
|
|
||||||
Add programmatic methods to clear specific route caches after content updates:
|
|
||||||
Rsx_Static_Cache::clear('/blog/post-slug')
|
|
||||||
Rsx_Static_Cache::clear_pattern('/blog/*')
|
|
||||||
|
|
||||||
|
|
||||||
RELATED DOCUMENTATION
|
|
||||||
|
|
||||||
- php artisan rsx:man routing (Route system and attributes)
|
|
||||||
- php artisan rsx:man caching (General caching concepts)
|
|
||||||
- php artisan rsx:man console_debug (Debug output configuration)
|
|
||||||
- php artisan rsx:man auth (Authentication system)
|
|
||||||
|
|
||||||
|
|
||||||
EXAMPLES
|
|
||||||
|
|
||||||
Basic static page:
|
|
||||||
|
|
||||||
#[Static_Page]
|
|
||||||
#[Route('/')]
|
|
||||||
#[Auth('Permission::anybody()')]
|
|
||||||
public static function index(Request $request, array $params = [])
|
|
||||||
{
|
|
||||||
return view('frontend.index', [
|
|
||||||
'featured_products' => Product_Model::where('featured', true)->get(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Static page with redirect:
|
|
||||||
|
|
||||||
#[Static_Page]
|
|
||||||
#[Route('/old-page')]
|
|
||||||
#[Auth('Permission::anybody()')]
|
|
||||||
public static function old_page(Request $request, array $params = [])
|
|
||||||
{
|
|
||||||
return redirect('/new-page', 301);
|
|
||||||
}
|
|
||||||
|
|
||||||
NOT suitable for FPC (query-dependent):
|
|
||||||
|
|
||||||
// ❌ DO NOT use #[Static_Page] - depends on GET parameters
|
|
||||||
#[Route('/search')]
|
|
||||||
#[Auth('Permission::anybody()')]
|
|
||||||
public static function search(Request $request, array $params = [])
|
|
||||||
{
|
|
||||||
$query = $params['q'] ?? '';
|
|
||||||
return view('search.results', [
|
|
||||||
'results' => Product_Model::search($query)->get(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Pre-generate cache in deployment script:
|
|
||||||
|
|
||||||
#!/bin/bash
|
|
||||||
# deployment.sh
|
|
||||||
|
|
||||||
# Deploy application
|
|
||||||
git pull
|
|
||||||
composer install --no-dev --optimize-autoloader
|
|
||||||
php artisan migrate
|
|
||||||
|
|
||||||
# Pre-generate static caches for high-traffic pages
|
|
||||||
php artisan rsx:ssr_fpc:create /
|
|
||||||
php artisan rsx:ssr_fpc:create /about
|
|
||||||
php artisan rsx:ssr_fpc:create /pricing
|
|
||||||
php artisan rsx:ssr_fpc:create /contact
|
|
||||||
|
|
||||||
# Note: This is optional - caches will auto-generate on first request
|
|
||||||
|
|
||||||
|
|
||||||
================================================================================
|
|
||||||
360
bin/fpc-proxy.js
Executable file
360
bin/fpc-proxy.js
Executable 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
61
bin/ssr-server.js
Executable 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);
|
||||||
|
});
|
||||||
@@ -524,8 +524,9 @@ For mechanical thinkers who see structure, not visuals. Write `<User_Card>` not
|
|||||||
|
|
||||||
**Interpolation**: `<%= escaped %>` | `<%!= unescaped %>` | `<%br= newlines %>` | `<% javascript %>`
|
**Interpolation**: `<%= escaped %>` | `<%!= unescaped %>` | `<%br= newlines %>` | `<% javascript %>`
|
||||||
**Conditional Attributes** `<input <% if (this.args.required) { %>required="required"<% } %> />`
|
**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
|
**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
|
**Validation**: `<% if (!this.args.required) throw new Error('Missing arg'); %>` - Fail loud in template
|
||||||
|
|
||||||
### Simple Components (No JS File)
|
### 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
|
- `this.state` for UI state, `this.args` + `reload()` for refetch
|
||||||
- `Controller.method()` not `$.ajax()` - #[Ajax_Endpoint] auto-callable
|
- `Controller.method()` not `$.ajax()` - #[Ajax_Endpoint] auto-callable
|
||||||
- `on_create/render/stop` sync; `this.sid()` → component, `$(el).component()` → component
|
- `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()`.
|
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()`.
|
Form modals: `Modal.form({title, component, on_submit})` - component must implement `vals()`.
|
||||||
|
|
||||||
Reusable modals: Extend `Modal_Abstract`, implement static `show()`.
|
Reusable modals: Extend `Modal_Abstract`, implement static `show()`.
|
||||||
|
|||||||
24
node_modules/.package-lock.json
generated
vendored
24
node_modules/.package-lock.json
generated
vendored
@@ -2224,9 +2224,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jqhtml/core": {
|
"node_modules/@jqhtml/core": {
|
||||||
"version": "2.3.41",
|
"version": "2.3.42",
|
||||||
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/core/-/core-2.3.41.tgz",
|
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/core/-/core-2.3.42.tgz",
|
||||||
"integrity": "sha512-Owf8Rf7yjG+WSRCPTXtTg+pFpWbTB+MnB/g2Clo6rVWZ5JxEqFZfmKIDx6lSX30pz16ph3RShe9Ijjc8V89S3w==",
|
"integrity": "sha512-am+iSzLuM3yuTQIaguep3kQ1eFB/jOAeB+C26aYoQyuH4E15K3AvPvy7VfBLP5+80WeIZP7oFiNHeJ6OMKg8iA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||||
@@ -2250,9 +2250,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jqhtml/parser": {
|
"node_modules/@jqhtml/parser": {
|
||||||
"version": "2.3.41",
|
"version": "2.3.42",
|
||||||
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/parser/-/parser-2.3.41.tgz",
|
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/parser/-/parser-2.3.42.tgz",
|
||||||
"integrity": "sha512-q6pT+eqWQf0qEgxzb61nERro5NkIeBnu/DQPUqRNZdywAqam8AHYlwzA5n54BlghJ6m/61DVeRMSHoVu1UV6lA==",
|
"integrity": "sha512-sIj+wG6X3SdJLNchL3NpptN6Osh+WdB8CQR8SkzTcjZ+AvI7O/WNdxdDnQWRU3N51sIfk413v0searcjlMZKcg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
@@ -2290,9 +2290,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jqhtml/ssr": {
|
"node_modules/@jqhtml/ssr": {
|
||||||
"version": "2.3.41",
|
"version": "2.3.42",
|
||||||
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/ssr/-/ssr-2.3.41.tgz",
|
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/ssr/-/ssr-2.3.42.tgz",
|
||||||
"integrity": "sha512-9uNQ7QaaBBU49ncEKxv9uoajfxe3/vt1wLOMrex81oqKB1PHFIkfQbQ1QcNakYgDTXMFkXKinH0O3qEROH9Lxw==",
|
"integrity": "sha512-0glVS5mIPD+mB6X1hyzwQSVFXNs4UoUj/dUDZ/nfClsP2jdy+nLcpHxO0NL5N4phA3NOytOQ34sePp5Sx0a/gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^3.7.1",
|
||||||
@@ -2386,9 +2386,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jqhtml/vscode-extension": {
|
"node_modules/@jqhtml/vscode-extension": {
|
||||||
"version": "2.3.41",
|
"version": "2.3.42",
|
||||||
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.41.tgz",
|
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.42.tgz",
|
||||||
"integrity": "sha512-CB3tIppMT3cVLiOIAAxymMtLAae2FJfkf6aFSkQOiONK47h10k2/QkkXFJwXyRRnzbw+ijuhBCDodiLlJtt8aw==",
|
"integrity": "sha512-+FASm90uV9SgSRtFRQiUelQ7JShx646XHg/SNzf43TaNaOMzz2SDz7fQS/4IbkYD2Qwa9vFPs5vTUKVc34rtRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.74.0"
|
"vscode": "^1.74.0"
|
||||||
|
|||||||
2
node_modules/@jqhtml/core/dist/component.d.ts.map
generated
vendored
2
node_modules/@jqhtml/core/dist/component.d.ts.map
generated
vendored
@@ -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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"}
|
||||||
169
node_modules/@jqhtml/core/dist/index.cjs
generated
vendored
169
node_modules/@jqhtml/core/dist/index.cjs
generated
vendored
@@ -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
|
* JQHTML v2 Component Base Class
|
||||||
*
|
*
|
||||||
@@ -3515,7 +3650,7 @@ class Jqhtml_Component {
|
|||||||
}
|
}
|
||||||
return;
|
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 cache_key = null;
|
||||||
let uncacheable_property;
|
let uncacheable_property;
|
||||||
if (typeof this.cache_id === 'function') {
|
if (typeof this.cache_id === 'function') {
|
||||||
@@ -3534,6 +3669,19 @@ class Jqhtml_Component {
|
|||||||
cache_key = result.key;
|
cache_key = result.key;
|
||||||
uncacheable_property = result.uncacheable_property;
|
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
|
// Store cache key for later use
|
||||||
this._cache_key = cache_key;
|
this._cache_key = cache_key;
|
||||||
// If cache_key is null, args are not serializable - skip load deduplication and caching
|
// If cache_key is null, args are not serializable - skip load deduplication and caching
|
||||||
@@ -3653,6 +3801,10 @@ class Jqhtml_Component {
|
|||||||
}
|
}
|
||||||
// Freeze this.data
|
// Freeze this.data
|
||||||
this.__data_frozen = true;
|
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
|
// Calculate if data changed
|
||||||
const data_after_load = JSON.stringify(this.data);
|
const data_after_load = JSON.stringify(this.data);
|
||||||
const data_changed = data_before_load !== null && data_after_load !== data_before_load;
|
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
|
// 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
|
// Default export with all functionality
|
||||||
const jqhtml = {
|
const jqhtml = {
|
||||||
// Core
|
// Core
|
||||||
@@ -5433,7 +5585,13 @@ const jqhtml = {
|
|||||||
// Required for ES6 class instances to be stored and restored from localStorage
|
// Required for ES6 class instances to be stored and restored from localStorage
|
||||||
register_cache_class,
|
register_cache_class,
|
||||||
// Boot - hydrate server-rendered component placeholders
|
// 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
|
// Auto-register on window for browser environments
|
||||||
// This is REQUIRED for compiled templates which use window.jqhtml.register_template()
|
// 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.Load_Coordinator = Load_Coordinator;
|
||||||
exports.applyDebugDelay = applyDebugDelay;
|
exports.applyDebugDelay = applyDebugDelay;
|
||||||
exports.boot = boot;
|
exports.boot = boot;
|
||||||
|
exports.clear_preload_data = clear_preload_data;
|
||||||
exports.create_component = create_component;
|
exports.create_component = create_component;
|
||||||
exports.default = jqhtml;
|
exports.default = jqhtml;
|
||||||
exports.devWarn = devWarn;
|
exports.devWarn = devWarn;
|
||||||
exports.escape_html = escape_html;
|
exports.escape_html = escape_html;
|
||||||
exports.escape_html_nl2br = escape_html_nl2br;
|
exports.escape_html_nl2br = escape_html_nl2br;
|
||||||
exports.extract_slots = extract_slots;
|
exports.extract_slots = extract_slots;
|
||||||
|
exports.get_captured_data = get_captured_data;
|
||||||
exports.get_component_class = get_component_class;
|
exports.get_component_class = get_component_class;
|
||||||
exports.get_component_names = get_component_names;
|
exports.get_component_names = get_component_names;
|
||||||
exports.get_registered_templates = get_registered_templates;
|
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_component = register_component;
|
||||||
exports.register_template = register_template;
|
exports.register_template = register_template;
|
||||||
exports.render_template = render_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;
|
exports.version = version;
|
||||||
//# sourceMappingURL=index.cjs.map
|
//# sourceMappingURL=index.cjs.map
|
||||||
|
|||||||
2
node_modules/@jqhtml/core/dist/index.cjs.map
generated
vendored
2
node_modules/@jqhtml/core/dist/index.cjs.map
generated
vendored
File diff suppressed because one or more lines are too long
8
node_modules/@jqhtml/core/dist/index.d.ts
generated
vendored
8
node_modules/@jqhtml/core/dist/index.d.ts
generated
vendored
@@ -29,6 +29,9 @@ export { Jqhtml_Local_Storage, register_cache_class };
|
|||||||
export type { CacheMode } from './local-storage.js';
|
export type { CacheMode } from './local-storage.js';
|
||||||
import { Load_Coordinator } from './load-coordinator.js';
|
import { Load_Coordinator } from './load-coordinator.js';
|
||||||
export { Load_Coordinator };
|
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 declare const version = "__VERSION__";
|
||||||
export interface DebugSettings {
|
export interface DebugSettings {
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
@@ -89,6 +92,11 @@ declare const jqhtml: {
|
|||||||
get_cache_mode(): import("./local-storage.js").CacheMode;
|
get_cache_mode(): import("./local-storage.js").CacheMode;
|
||||||
register_cache_class: typeof register_cache_class;
|
register_cache_class: typeof register_cache_class;
|
||||||
boot: typeof boot;
|
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;
|
export default jqhtml;
|
||||||
//# sourceMappingURL=index.d.ts.map
|
//# sourceMappingURL=index.d.ts.map
|
||||||
2
node_modules/@jqhtml/core/dist/index.d.ts.map
generated
vendored
2
node_modules/@jqhtml/core/dist/index.d.ts.map
generated
vendored
@@ -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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"}
|
||||||
166
node_modules/@jqhtml/core/dist/index.js
generated
vendored
166
node_modules/@jqhtml/core/dist/index.js
generated
vendored
@@ -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
|
* JQHTML v2 Component Base Class
|
||||||
*
|
*
|
||||||
@@ -3511,7 +3646,7 @@ class Jqhtml_Component {
|
|||||||
}
|
}
|
||||||
return;
|
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 cache_key = null;
|
||||||
let uncacheable_property;
|
let uncacheable_property;
|
||||||
if (typeof this.cache_id === 'function') {
|
if (typeof this.cache_id === 'function') {
|
||||||
@@ -3530,6 +3665,19 @@ class Jqhtml_Component {
|
|||||||
cache_key = result.key;
|
cache_key = result.key;
|
||||||
uncacheable_property = result.uncacheable_property;
|
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
|
// Store cache key for later use
|
||||||
this._cache_key = cache_key;
|
this._cache_key = cache_key;
|
||||||
// If cache_key is null, args are not serializable - skip load deduplication and caching
|
// If cache_key is null, args are not serializable - skip load deduplication and caching
|
||||||
@@ -3649,6 +3797,10 @@ class Jqhtml_Component {
|
|||||||
}
|
}
|
||||||
// Freeze this.data
|
// Freeze this.data
|
||||||
this.__data_frozen = true;
|
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
|
// Calculate if data changed
|
||||||
const data_after_load = JSON.stringify(this.data);
|
const data_after_load = JSON.stringify(this.data);
|
||||||
const data_changed = data_before_load !== null && data_after_load !== data_before_load;
|
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
|
// 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
|
// Default export with all functionality
|
||||||
const jqhtml = {
|
const jqhtml = {
|
||||||
// Core
|
// Core
|
||||||
@@ -5429,7 +5581,13 @@ const jqhtml = {
|
|||||||
// Required for ES6 class instances to be stored and restored from localStorage
|
// Required for ES6 class instances to be stored and restored from localStorage
|
||||||
register_cache_class,
|
register_cache_class,
|
||||||
// Boot - hydrate server-rendered component placeholders
|
// 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
|
// Auto-register on window for browser environments
|
||||||
// This is REQUIRED for compiled templates which use window.jqhtml.register_template()
|
// 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
|
//# sourceMappingURL=index.js.map
|
||||||
|
|||||||
2
node_modules/@jqhtml/core/dist/index.js.map
generated
vendored
2
node_modules/@jqhtml/core/dist/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
168
node_modules/@jqhtml/core/dist/jqhtml-core.esm.js
generated
vendored
168
node_modules/@jqhtml/core/dist/jqhtml-core.esm.js
generated
vendored
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* JQHTML Core v2.3.41
|
* JQHTML Core v2.3.42
|
||||||
* (c) 2025 JQHTML Team
|
* (c) 2025 JQHTML Team
|
||||||
* Released under the MIT License
|
* 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
|
* JQHTML v2 Component Base Class
|
||||||
*
|
*
|
||||||
@@ -3516,7 +3651,7 @@ class Jqhtml_Component {
|
|||||||
}
|
}
|
||||||
return;
|
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 cache_key = null;
|
||||||
let uncacheable_property;
|
let uncacheable_property;
|
||||||
if (typeof this.cache_id === 'function') {
|
if (typeof this.cache_id === 'function') {
|
||||||
@@ -3535,6 +3670,19 @@ class Jqhtml_Component {
|
|||||||
cache_key = result.key;
|
cache_key = result.key;
|
||||||
uncacheable_property = result.uncacheable_property;
|
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
|
// Store cache key for later use
|
||||||
this._cache_key = cache_key;
|
this._cache_key = cache_key;
|
||||||
// If cache_key is null, args are not serializable - skip load deduplication and caching
|
// If cache_key is null, args are not serializable - skip load deduplication and caching
|
||||||
@@ -3654,6 +3802,10 @@ class Jqhtml_Component {
|
|||||||
}
|
}
|
||||||
// Freeze this.data
|
// Freeze this.data
|
||||||
this.__data_frozen = true;
|
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
|
// Calculate if data changed
|
||||||
const data_after_load = JSON.stringify(this.data);
|
const data_after_load = JSON.stringify(this.data);
|
||||||
const data_changed = data_before_load !== null && data_after_load !== data_before_load;
|
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
|
// 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
|
// Default export with all functionality
|
||||||
const jqhtml = {
|
const jqhtml = {
|
||||||
// Core
|
// Core
|
||||||
@@ -5434,7 +5586,13 @@ const jqhtml = {
|
|||||||
// Required for ES6 class instances to be stored and restored from localStorage
|
// Required for ES6 class instances to be stored and restored from localStorage
|
||||||
register_cache_class,
|
register_cache_class,
|
||||||
// Boot - hydrate server-rendered component placeholders
|
// 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
|
// Auto-register on window for browser environments
|
||||||
// This is REQUIRED for compiled templates which use window.jqhtml.register_template()
|
// 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
|
//# sourceMappingURL=jqhtml-core.esm.js.map
|
||||||
|
|||||||
2
node_modules/@jqhtml/core/dist/jqhtml-core.esm.js.map
generated
vendored
2
node_modules/@jqhtml/core/dist/jqhtml-core.esm.js.map
generated
vendored
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
69
node_modules/@jqhtml/core/dist/preload-data.d.ts
generated
vendored
Executable 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
1
node_modules/@jqhtml/core/dist/preload-data.d.ts.map
generated
vendored
Executable 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"}
|
||||||
2
node_modules/@jqhtml/core/package.json
generated
vendored
2
node_modules/@jqhtml/core/package.json
generated
vendored
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jqhtml/core",
|
"name": "@jqhtml/core",
|
||||||
"version": "2.3.41",
|
"version": "2.3.42",
|
||||||
"description": "Core runtime library for JQHTML",
|
"description": "Core runtime library for JQHTML",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
2
node_modules/@jqhtml/parser/dist/codegen.js
generated
vendored
2
node_modules/@jqhtml/parser/dist/codegen.js
generated
vendored
@@ -1385,7 +1385,7 @@ export class CodeGenerator {
|
|||||||
for (const [name, component] of this.components) {
|
for (const [name, component] of this.components) {
|
||||||
code += `// Component: ${name}\n`;
|
code += `// Component: ${name}\n`;
|
||||||
code += `jqhtml_components.set('${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 += ` name: '${name}',\n`;
|
||||||
code += ` tag: '${component.tagName}',\n`;
|
code += ` tag: '${component.tagName}',\n`;
|
||||||
code += ` defaultAttributes: ${this.serializeAttributeObject(component.defaultAttributes)},\n`;
|
code += ` defaultAttributes: ${this.serializeAttributeObject(component.defaultAttributes)},\n`;
|
||||||
|
|||||||
2
node_modules/@jqhtml/parser/package.json
generated
vendored
2
node_modules/@jqhtml/parser/package.json
generated
vendored
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jqhtml/parser",
|
"name": "@jqhtml/parser",
|
||||||
"version": "2.3.41",
|
"version": "2.3.42",
|
||||||
"description": "JQHTML template parser - converts templates to JavaScript",
|
"description": "JQHTML template parser - converts templates to JavaScript",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
136
node_modules/@jqhtml/ssr/README.md
generated
vendored
136
node_modules/@jqhtml/ssr/README.md
generated
vendored
@@ -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
|
## File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
63
node_modules/@jqhtml/ssr/SPECIFICATION.md
generated
vendored
63
node_modules/@jqhtml/ssr/SPECIFICATION.md
generated
vendored
@@ -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
|
## Future Considerations
|
||||||
|
|
||||||
### Streaming SSR
|
### Streaming SSR
|
||||||
|
|||||||
2
node_modules/@jqhtml/ssr/package.json
generated
vendored
2
node_modules/@jqhtml/ssr/package.json
generated
vendored
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jqhtml/ssr",
|
"name": "@jqhtml/ssr",
|
||||||
"version": "2.3.41",
|
"version": "2.3.42",
|
||||||
"description": "Server-Side Rendering for JQHTML components - renders components to HTML for SEO",
|
"description": "Server-Side Rendering for JQHTML components - renders components to HTML for SEO",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
66
node_modules/@jqhtml/ssr/src/environment.js
generated
vendored
66
node_modules/@jqhtml/ssr/src/environment.js
generated
vendored
@@ -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
|
* Render a component to HTML
|
||||||
* @param {string} componentName - Component name to render
|
* @param {string} componentName - Component name to render
|
||||||
@@ -161,6 +213,12 @@ class SSREnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const $ = global.$;
|
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
|
// Create container
|
||||||
const $container = $('<div>');
|
const $container = $('<div>');
|
||||||
@@ -188,10 +246,16 @@ class SSREnvironment {
|
|||||||
// Get rendered HTML
|
// Get rendered HTML
|
||||||
const html = component.$.prop('outerHTML');
|
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
|
// Export cache state
|
||||||
const cache = this.storage.exportAll();
|
const cache = this.storage.exportAll();
|
||||||
|
|
||||||
return { html, cache };
|
return { html, cache, preload };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
24
node_modules/@jqhtml/ssr/src/protocol.js
generated
vendored
24
node_modules/@jqhtml/ssr/src/protocol.js
generated
vendored
@@ -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)) {
|
if (!validTypes.includes(parsed.type)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -396,12 +396,12 @@ function errorResponse(id, code, message, stack) {
|
|||||||
* @param {object} timing - Timing info { total_ms, bundle_load_ms, render_ms }
|
* @param {object} timing - Timing info { total_ms, bundle_load_ms, render_ms }
|
||||||
* @returns {string} JSON string with newline
|
* @returns {string} JSON string with newline
|
||||||
*/
|
*/
|
||||||
function renderResponse(id, html, cache, timing) {
|
function renderResponse(id, html, cache, timing, preload) {
|
||||||
return successResponse(id, {
|
const payload = { html, cache, timing };
|
||||||
html,
|
if (preload && preload.length > 0) {
|
||||||
cache,
|
payload.preload = preload;
|
||||||
timing
|
}
|
||||||
});
|
return successResponse(id, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -424,6 +424,15 @@ function flushCacheResponse(id, flushed) {
|
|||||||
return successResponse(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
|
* MessageBuffer - Handles newline-delimited message framing
|
||||||
*
|
*
|
||||||
@@ -483,5 +492,6 @@ module.exports = {
|
|||||||
renderSpaResponse,
|
renderSpaResponse,
|
||||||
pingResponse,
|
pingResponse,
|
||||||
flushCacheResponse,
|
flushCacheResponse,
|
||||||
|
shutdownResponse,
|
||||||
MessageBuffer
|
MessageBuffer
|
||||||
};
|
};
|
||||||
|
|||||||
27
node_modules/@jqhtml/ssr/src/server.js
generated
vendored
27
node_modules/@jqhtml/ssr/src/server.js
generated
vendored
@@ -17,6 +17,7 @@ const {
|
|||||||
renderSpaResponse,
|
renderSpaResponse,
|
||||||
pingResponse,
|
pingResponse,
|
||||||
flushCacheResponse,
|
flushCacheResponse,
|
||||||
|
shutdownResponse,
|
||||||
errorResponse,
|
errorResponse,
|
||||||
MessageBuffer
|
MessageBuffer
|
||||||
} = require('./protocol.js');
|
} = require('./protocol.js');
|
||||||
@@ -158,6 +159,12 @@ class SSRServer {
|
|||||||
response = await this._handleRenderSpa(request);
|
response = await this._handleRenderSpa(request);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'shutdown':
|
||||||
|
socket.write(shutdownResponse(request.id));
|
||||||
|
await this.stop();
|
||||||
|
process.exit(0);
|
||||||
|
return;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
response = errorResponse(
|
response = errorResponse(
|
||||||
request.id,
|
request.id,
|
||||||
@@ -253,11 +260,15 @@ class SSRServer {
|
|||||||
// Initialize environment
|
// Initialize environment
|
||||||
env.init();
|
env.init();
|
||||||
|
|
||||||
// Execute bundle code
|
// Execute bundle code (scoped IIFE prevents class declaration leaks across requests)
|
||||||
const execStartTime = Date.now();
|
const execStartTime = Date.now();
|
||||||
env.execute(bundleCode, `bundles:${cacheKey}`);
|
env.executeScoped(bundleCode, `bundles:${cacheKey}`);
|
||||||
bundleLoadMs += Date.now() - execStartTime;
|
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
|
// Check if jqhtml loaded
|
||||||
if (!env.isJqhtmlReady()) {
|
if (!env.isJqhtmlReady()) {
|
||||||
throw new Error('jqhtml runtime not available after loading bundles');
|
throw new Error('jqhtml runtime not available after loading bundles');
|
||||||
@@ -284,7 +295,7 @@ class SSRServer {
|
|||||||
total_ms: totalMs,
|
total_ms: totalMs,
|
||||||
bundle_load_ms: bundleLoadMs,
|
bundle_load_ms: bundleLoadMs,
|
||||||
render_ms: renderMs
|
render_ms: renderMs
|
||||||
});
|
}, result.preload);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Determine error type
|
// Determine error type
|
||||||
@@ -499,4 +510,14 @@ Options:
|
|||||||
await server.stop();
|
await server.stop();
|
||||||
process.exit(0);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
2
node_modules/@jqhtml/vscode-extension/.version
generated
vendored
2
node_modules/@jqhtml/vscode-extension/.version
generated
vendored
@@ -1 +1 @@
|
|||||||
2.3.41
|
2.3.42
|
||||||
|
|||||||
Binary file not shown.
2
node_modules/@jqhtml/vscode-extension/package.json
generated
vendored
2
node_modules/@jqhtml/vscode-extension/package.json
generated
vendored
@@ -2,7 +2,7 @@
|
|||||||
"name": "@jqhtml/vscode-extension",
|
"name": "@jqhtml/vscode-extension",
|
||||||
"displayName": "JQHTML",
|
"displayName": "JQHTML",
|
||||||
"description": "Syntax highlighting and language support for JQHTML template files",
|
"description": "Syntax highlighting and language support for JQHTML template files",
|
||||||
"version": "2.3.41",
|
"version": "2.3.42",
|
||||||
"publisher": "jqhtml",
|
"publisher": "jqhtml",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -2676,9 +2676,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jqhtml/core": {
|
"node_modules/@jqhtml/core": {
|
||||||
"version": "2.3.41",
|
"version": "2.3.42",
|
||||||
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/core/-/core-2.3.41.tgz",
|
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/core/-/core-2.3.42.tgz",
|
||||||
"integrity": "sha512-Owf8Rf7yjG+WSRCPTXtTg+pFpWbTB+MnB/g2Clo6rVWZ5JxEqFZfmKIDx6lSX30pz16ph3RShe9Ijjc8V89S3w==",
|
"integrity": "sha512-am+iSzLuM3yuTQIaguep3kQ1eFB/jOAeB+C26aYoQyuH4E15K3AvPvy7VfBLP5+80WeIZP7oFiNHeJ6OMKg8iA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||||
@@ -2702,9 +2702,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jqhtml/parser": {
|
"node_modules/@jqhtml/parser": {
|
||||||
"version": "2.3.41",
|
"version": "2.3.42",
|
||||||
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/parser/-/parser-2.3.41.tgz",
|
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/parser/-/parser-2.3.42.tgz",
|
||||||
"integrity": "sha512-q6pT+eqWQf0qEgxzb61nERro5NkIeBnu/DQPUqRNZdywAqam8AHYlwzA5n54BlghJ6m/61DVeRMSHoVu1UV6lA==",
|
"integrity": "sha512-sIj+wG6X3SdJLNchL3NpptN6Osh+WdB8CQR8SkzTcjZ+AvI7O/WNdxdDnQWRU3N51sIfk413v0searcjlMZKcg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
@@ -2742,9 +2742,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jqhtml/ssr": {
|
"node_modules/@jqhtml/ssr": {
|
||||||
"version": "2.3.41",
|
"version": "2.3.42",
|
||||||
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/ssr/-/ssr-2.3.41.tgz",
|
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/ssr/-/ssr-2.3.42.tgz",
|
||||||
"integrity": "sha512-9uNQ7QaaBBU49ncEKxv9uoajfxe3/vt1wLOMrex81oqKB1PHFIkfQbQ1QcNakYgDTXMFkXKinH0O3qEROH9Lxw==",
|
"integrity": "sha512-0glVS5mIPD+mB6X1hyzwQSVFXNs4UoUj/dUDZ/nfClsP2jdy+nLcpHxO0NL5N4phA3NOytOQ34sePp5Sx0a/gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^3.7.1",
|
||||||
@@ -2838,9 +2838,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jqhtml/vscode-extension": {
|
"node_modules/@jqhtml/vscode-extension": {
|
||||||
"version": "2.3.41",
|
"version": "2.3.42",
|
||||||
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.41.tgz",
|
"resolved": "http://npm.internal.hanson.xyz/@jqhtml/vscode-extension/-/vscode-extension-2.3.42.tgz",
|
||||||
"integrity": "sha512-CB3tIppMT3cVLiOIAAxymMtLAae2FJfkf6aFSkQOiONK47h10k2/QkkXFJwXyRRnzbw+ijuhBCDodiLlJtt8aw==",
|
"integrity": "sha512-+FASm90uV9SgSRtFRQiUelQ7JShx646XHg/SNzf43TaNaOMzz2SDz7fQS/4IbkYD2Qwa9vFPs5vTUKVc34rtRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.74.0"
|
"vscode": "^1.74.0"
|
||||||
|
|||||||
16
supervisor/fpc-proxy.conf
Executable file
16
supervisor/fpc-proxy.conf
Executable 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
|
||||||
Reference in New Issue
Block a user