From daa9bb2fb1b83cdde1b3650cf1c8e65ac4d62eff Mon Sep 17 00:00:00 2001 From: root Date: Thu, 12 Mar 2026 19:09:07 +0000 Subject: [PATCH] Framework updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- FPC_IMPLEMENTATION_PLAN.md | 93 ---- app/RSpade/Core/Dispatch/Dispatcher.php | 35 ++ app/RSpade/Core/FPC/Rsx_FPC.php | 105 +++++ app/RSpade/Core/Js/Rsx.js | 39 +- app/RSpade/Core/Js/Rsx_Behaviors.js | 1 + app/RSpade/Core/Js/Rsx_Droppable.js | 1 + app/RSpade/Core/Js/Rsx_Init.js | 1 + app/RSpade/Core/Js/Rsx_View_Transitions.js | 2 + app/RSpade/Core/Js/Width_Group.js | 2 + app/RSpade/Core/Locks/RsxLocks.php | 1 + .../Core/Manifest/_Manifest_Cache_Helper.php | 65 +++ app/RSpade/Core/SSR/Rsx_SSR.php | 398 ++++++++++++++++++ app/RSpade/Core/Session/Session.php | 12 +- .../Integrations/Jqhtml/Jqhtml_Integration.js | 19 + app/RSpade/Lib/Flash/Flash_Alert.php | 7 + app/RSpade/man/fpc.txt | 258 ++++++++++++ app/RSpade/man/ssr.txt | 204 +++++++++ app/RSpade/man/ssr_fpc.txt | 359 ---------------- bin/fpc-proxy.js | 360 ++++++++++++++++ bin/ssr-server.js | 61 +++ docs/CLAUDE.dist.md | 14 +- node_modules/.package-lock.json | 24 +- .../@jqhtml/core/dist/component.d.ts.map | 2 +- node_modules/@jqhtml/core/dist/index.cjs | 169 +++++++- node_modules/@jqhtml/core/dist/index.cjs.map | 2 +- node_modules/@jqhtml/core/dist/index.d.ts | 8 + node_modules/@jqhtml/core/dist/index.d.ts.map | 2 +- node_modules/@jqhtml/core/dist/index.js | 166 +++++++- node_modules/@jqhtml/core/dist/index.js.map | 2 +- .../@jqhtml/core/dist/jqhtml-core.esm.js | 168 +++++++- .../@jqhtml/core/dist/jqhtml-core.esm.js.map | 2 +- .../@jqhtml/core/dist/preload-data.d.ts | 69 +++ .../@jqhtml/core/dist/preload-data.d.ts.map | 1 + node_modules/@jqhtml/core/package.json | 2 +- node_modules/@jqhtml/parser/dist/codegen.js | 2 +- node_modules/@jqhtml/parser/package.json | 2 +- node_modules/@jqhtml/ssr/README.md | 136 ++++++ node_modules/@jqhtml/ssr/SPECIFICATION.md | 63 +++ node_modules/@jqhtml/ssr/package.json | 2 +- node_modules/@jqhtml/ssr/src/environment.js | 66 ++- node_modules/@jqhtml/ssr/src/protocol.js | 24 +- node_modules/@jqhtml/ssr/src/server.js | 27 +- .../@jqhtml/vscode-extension/.version | 2 +- ...ix => jqhtml-vscode-extension-2.3.42.vsix} | Bin 52947 -> 52946 bytes .../@jqhtml/vscode-extension/package.json | 2 +- package-lock.json | 24 +- supervisor/fpc-proxy.conf | 16 + 47 files changed, 2495 insertions(+), 525 deletions(-) delete mode 100755 FPC_IMPLEMENTATION_PLAN.md create mode 100755 app/RSpade/Core/FPC/Rsx_FPC.php create mode 100755 app/RSpade/Core/SSR/Rsx_SSR.php create mode 100755 app/RSpade/man/fpc.txt create mode 100755 app/RSpade/man/ssr.txt delete mode 100755 app/RSpade/man/ssr_fpc.txt create mode 100755 bin/fpc-proxy.js create mode 100755 bin/ssr-server.js create mode 100755 node_modules/@jqhtml/core/dist/preload-data.d.ts create mode 100755 node_modules/@jqhtml/core/dist/preload-data.d.ts.map rename node_modules/@jqhtml/vscode-extension/{jqhtml-vscode-extension-2.3.41.vsix => jqhtml-vscode-extension-2.3.42.vsix} (89%) create mode 100755 supervisor/fpc-proxy.conf diff --git a/FPC_IMPLEMENTATION_PLAN.md b/FPC_IMPLEMENTATION_PLAN.md deleted file mode 100755 index 76e5d0570..000000000 --- a/FPC_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -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 diff --git a/app/RSpade/Core/Dispatch/Dispatcher.php b/app/RSpade/Core/Dispatch/Dispatcher.php index a7ad0ed2c..78c96e76b 100644 --- a/app/RSpade/Core/Dispatch/Dispatcher.php +++ b/app/RSpade/Core/Dispatch/Dispatcher.php @@ -297,6 +297,35 @@ class Dispatcher Debugger::console_debug('DISPATCH', 'Matched route to ' . $handler_class . '::' . $handler_method . ' params: ' . json_encode($params)); + // --- FPC Detection --- + // Check if route has #[FPC] attribute and conditions are met for caching + $has_fpc = false; + try { + $fpc_metadata = Manifest::php_get_metadata_by_fqcn($handler_class); + $fpc_method_data = $fpc_metadata['public_static_methods'][$handler_method] ?? null; + + if ($fpc_method_data && isset($fpc_method_data['attributes']['FPC'])) { + // FPC only active for unauthenticated GET requests without POST/FILE data + if ($route_method === 'GET' && empty($_POST) && empty($_FILES)) { + \App\RSpade\Core\Session\Session::init(); + + if (!\App\RSpade\Core\Session\Session::is_logged_in()) { + $has_fpc = true; + + // Blank all cookies except session to prevent tainted output + foreach ($_COOKIE as $key => $value) { + if ($key !== 'rsx') { + unset($_COOKIE[$key]); + } + } + } + } + } + } catch (\Throwable $e) { + console_debug('FPC', 'Metadata lookup failed: ' . $e->getMessage()); + } + // --- End FPC Detection --- + // Set current controller and action in Rsx for tracking $route_type = $route_match['type'] ?? 'standard'; \App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method, $params, $route_type); @@ -323,6 +352,12 @@ class Dispatcher // Convert result to response $response = static::__build_response($result); + // Add FPC header if conditions were met — signals the FPC proxy to cache this response + if ($has_fpc && $response instanceof \Symfony\Component\HttpFoundation\Response) { + $response->headers->set('X-RSpade-FPC', '1'); + $response->headers->remove('Set-Cookie'); + } + // Apply response transformations (HEAD body stripping, etc.) return static::__transform_response($response, $original_method); } diff --git a/app/RSpade/Core/FPC/Rsx_FPC.php b/app/RSpade/Core/FPC/Rsx_FPC.php new file mode 100755 index 000000000..ce98d0d81 --- /dev/null +++ b/app/RSpade/Core/FPC/Rsx_FPC.php @@ -0,0 +1,105 @@ +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; + } + } +} diff --git a/app/RSpade/Core/Js/Rsx.js b/app/RSpade/Core/Js/Rsx.js index 3da7e1f17..cfe01370f 100755 --- a/app/RSpade/Core/Js/Rsx.js +++ b/app/RSpade/Core/Js/Rsx.js @@ -222,6 +222,16 @@ class Rsx { return !window.rsxapp.debug; } + // Returns true when running inside the SSR Node server + static is_ssr() { + return !!window.__SSR__; + } + + // Returns true in the browser when hydrating an SSR-rendered page + static is_ssr_hydrate() { + return !!(window.rsxapp && window.rsxapp.page_data && window.rsxapp.page_data.ssr); + } + /** * Get the current logged-in user model instance * Returns the hydrated ORM model if available, or the raw data object @@ -717,24 +727,27 @@ class Rsx { Rsx.trigger(phase.event); } - // Ui refresh callbacks - Rsx.trigger_refresh(); - // All phases complete console_debug('RSX_INIT', 'Initialization complete'); - // TODO: Find a good wait to wait for all jqhtml components to load, then trigger on_ready and on('ready') emulating the top level last syntax that jqhtml components operateas, but as a standard js class (such as a page class). The biggest question is, how do we efficiently choose only the top level jqhtml components. do we only consider components cretaed directly on blade templates? that seams reasonable... + // Skip browser-only post-boot steps in SSR + if (!Rsx.is_ssr()) { + // Ui refresh callbacks + Rsx.trigger_refresh(); - // Trigger _debug_ready event - this is ONLY for tooling like rsx:debug - // DO NOT use this in application code - use on_app_ready() phase instead - // This event exists solely for debugging tools that need to run after full initialization - Rsx.trigger('_debug_ready'); + // TODO: Find a good wait to wait for all jqhtml components to load, then trigger on_ready and on('ready') emulating the top level last syntax that jqhtml components operateas, but as a standard js class (such as a page class). The biggest question is, how do we efficiently choose only the top level jqhtml components. do we only consider components cretaed directly on blade templates? that seams reasonable... - // Restore scroll position on page refresh - // Use requestAnimationFrame to ensure DOM is fully rendered after SPA action completes - requestAnimationFrame(() => { - Rsx._restore_scroll_on_refresh(); - }); + // Trigger _debug_ready event - this is ONLY for tooling like rsx:debug + // DO NOT use this in application code - use on_app_ready() phase instead + // This event exists solely for debugging tools that need to run after full initialization + Rsx.trigger('_debug_ready'); + + // Restore scroll position on page refresh + // Use requestAnimationFrame to ensure DOM is fully rendered after SPA action completes + requestAnimationFrame(() => { + Rsx._restore_scroll_on_refresh(); + }); + } } /** diff --git a/app/RSpade/Core/Js/Rsx_Behaviors.js b/app/RSpade/Core/Js/Rsx_Behaviors.js index a870d1f76..6fe9bb7cd 100755 --- a/app/RSpade/Core/Js/Rsx_Behaviors.js +++ b/app/RSpade/Core/Js/Rsx_Behaviors.js @@ -13,6 +13,7 @@ */ class Rsx_Behaviors { static _on_framework_core_init() { + if (Rsx.is_ssr()) return; Rsx_Behaviors._init_ignore_invalid_anchor_links(); Rsx_Behaviors._trim_copied_text(); } diff --git a/app/RSpade/Core/Js/Rsx_Droppable.js b/app/RSpade/Core/Js/Rsx_Droppable.js index 60c4a0801..b0ca55e6f 100755 --- a/app/RSpade/Core/Js/Rsx_Droppable.js +++ b/app/RSpade/Core/Js/Rsx_Droppable.js @@ -20,6 +20,7 @@ */ class Rsx_Droppable { static _on_framework_core_init() { + if (Rsx.is_ssr()) return; Rsx_Droppable._init(); } diff --git a/app/RSpade/Core/Js/Rsx_Init.js b/app/RSpade/Core/Js/Rsx_Init.js index ff0745eb5..742974fa7 100755 --- a/app/RSpade/Core/Js/Rsx_Init.js +++ b/app/RSpade/Core/Js/Rsx_Init.js @@ -7,6 +7,7 @@ class Rsx_Init { * Initializes the core environment and runs basic sanity checks */ static _on_framework_core_init() { + if (Rsx.is_ssr()) return; if (!Rsx.is_prod()) { Rsx_Init.__environment_checks(); } diff --git a/app/RSpade/Core/Js/Rsx_View_Transitions.js b/app/RSpade/Core/Js/Rsx_View_Transitions.js index 1d80e93d6..7b68f89a6 100755 --- a/app/RSpade/Core/Js/Rsx_View_Transitions.js +++ b/app/RSpade/Core/Js/Rsx_View_Transitions.js @@ -12,6 +12,8 @@ class Rsx_View_Transitions { * Checks for View Transitions API support and enables if available */ static _on_framework_core_init() { + if (Rsx.is_ssr()) return; + // Check if View Transitions API is supported if (!document.startViewTransition) { console_debug('VIEW_TRANSITIONS', 'View Transitions API not supported, skipping'); diff --git a/app/RSpade/Core/Js/Width_Group.js b/app/RSpade/Core/Js/Width_Group.js index 9cf4a290b..6c84c90e1 100755 --- a/app/RSpade/Core/Js/Width_Group.js +++ b/app/RSpade/Core/Js/Width_Group.js @@ -92,6 +92,8 @@ class Width_Group { * Initialize jQuery extensions */ static _on_framework_core_define() { + if (Rsx.is_ssr()) return; + /** * Add elements to a named width group * @param {string} group_name - Name of the width group diff --git a/app/RSpade/Core/Locks/RsxLocks.php b/app/RSpade/Core/Locks/RsxLocks.php index 5a728fca3..de57b88b2 100644 --- a/app/RSpade/Core/Locks/RsxLocks.php +++ b/app/RSpade/Core/Locks/RsxLocks.php @@ -40,6 +40,7 @@ class RsxLocks const LOCK_BUNDLE_BUILD = 'BUNDLE_BUILD'; // Bundle compilation operations const LOCK_MIGRATION = 'MIGRATION'; // Database migration operations const LOCK_FILE_WRITE = 'FILE_WRITE'; // File upload/storage operations + const LOCK_SSR_RENDER = 'SSR_RENDER'; // SSR server lifecycle + render operations // Site-specific lock prefix (appended with site_id) const LOCK_SITE_PREFIX = 'SITE_'; // e.g., SITE_1, SITE_2, etc. diff --git a/app/RSpade/Core/Manifest/_Manifest_Cache_Helper.php b/app/RSpade/Core/Manifest/_Manifest_Cache_Helper.php index aa23f5cc1..67f2063e2 100755 --- a/app/RSpade/Core/Manifest/_Manifest_Cache_Helper.php +++ b/app/RSpade/Core/Manifest/_Manifest_Cache_Helper.php @@ -146,6 +146,9 @@ class _Manifest_Cache_Helper } file_put_contents($cache_file, $php_content); + + // Write build key to separate file for non-PHP consumers (FPC proxy, etc.) + file_put_contents(dirname($cache_file) . '/build_key', Manifest::$data['hash']); } public static function _validate_cached_data() @@ -364,6 +367,68 @@ class _Manifest_Cache_Helper "To: public static function {$method_name}(...)\n" ); } + + // Check FPC attribute constraints + $has_fpc = false; + $has_spa = false; + + foreach ($method_info['attributes'] as $attr_name => $attr_instances) { + if ($attr_name === 'FPC' || str_ends_with($attr_name, '\\FPC')) { + $has_fpc = true; + } + if ($attr_name === 'SPA' || str_ends_with($attr_name, '\\SPA')) { + $has_spa = true; + } + } + + if ($has_fpc) { + $class_name = $metadata['class'] ?? 'Unknown'; + + if ($has_ajax_endpoint) { + throw new \RuntimeException( + "#[FPC] cannot be used on Ajax endpoints\n" . +"Class: {$class_name}\n" . +"Method: {$method_name}\n" . +"File: {$file_path}\n\n" . +"#[FPC] marks a route for full page caching. Ajax endpoints return JSON\n" . +"data, not HTML pages.\n\n" . +"Solution: Remove #[FPC] from this Ajax endpoint." + ); + } + + if ($has_spa) { + throw new \RuntimeException( + "#[FPC] cannot be used on #[SPA] methods\n" . +"Class: {$class_name}\n" . +"Method: {$method_name}\n" . +"File: {$file_path}\n\n" . +"SPA bootstrap methods return an empty shell that JavaScript populates.\n" . +"Caching this shell serves the same empty page for all SPA routes.\n\n" . +"Solution: Remove #[FPC] from this SPA method." + ); + } + + if ($has_task) { + throw new \RuntimeException( + "#[FPC] cannot be used on Task methods\n" . +"Class: {$class_name}\n" . +"Method: {$method_name}\n" . +"File: {$file_path}\n\n" . +"Solution: Remove #[FPC] from this Task method." + ); + } + + if (!$has_route) { + throw new \RuntimeException( + "#[FPC] requires #[Route] attribute\n" . +"Class: {$class_name}\n" . +"Method: {$method_name}\n" . +"File: {$file_path}\n\n" . +"#[FPC] can only be used on methods with a #[Route] attribute.\n\n" . +"Solution: Add #[Route('/path')] to this method, or remove #[FPC]." + ); + } + } } } } diff --git a/app/RSpade/Core/SSR/Rsx_SSR.php b/app/RSpade/Core/SSR/Rsx_SSR.php new file mode 100755 index 000000000..f150e4ce5 --- /dev/null +++ b/app/RSpade/Core/SSR/Rsx_SSR.php @@ -0,0 +1,398 @@ + 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; + } +} diff --git a/app/RSpade/Core/Session/Session.php b/app/RSpade/Core/Session/Session.php index 4b69b6ef7..e3abcf7ca 100644 --- a/app/RSpade/Core/Session/Session.php +++ b/app/RSpade/Core/Session/Session.php @@ -616,7 +616,17 @@ class Session extends Rsx_System_Model_Abstract return; } - self::__activate(); + self::init(); + + // If no session exists (anonymous visitor), store as request-scoped + // override without creating a session. The site_id will be available + // via get_site_id() for the duration of this request. + if (empty(self::$_session)) { + self::$_request_site_id_override = $site_id; + self::$_site = null; + + return; + } // Skip if already set if (self::get_site_id() === $site_id) { diff --git a/app/RSpade/Integrations/Jqhtml/Jqhtml_Integration.js b/app/RSpade/Integrations/Jqhtml/Jqhtml_Integration.js index 46a4c2c2f..6d3cc3cc3 100755 --- a/app/RSpade/Integrations/Jqhtml/Jqhtml_Integration.js +++ b/app/RSpade/Integrations/Jqhtml/Jqhtml_Integration.js @@ -33,6 +33,19 @@ class Jqhtml_Integration { * This runs during framework_modules_define, before any DOM processing. */ static _on_framework_modules_define() { + // ───────────────────────────────────────────────────────────────────── + // SSR Preload Data Injection + // + // If the page was SSR-rendered, window.rsxapp.ssr_preload contains + // captured component data from the SSR server. Seed jqhtml's preload + // cache so on_load() is skipped for components with matching data. + // Must happen before component registration / DOM hydration. + // ───────────────────────────────────────────────────────────────────── + if (window.rsxapp && window.rsxapp.page_data && window.rsxapp.page_data.ssr_preload && typeof jqhtml.set_preload_data === 'function') { + jqhtml.set_preload_data(window.rsxapp.page_data.ssr_preload); + console_debug('JQHTML_INIT', 'SSR preload data seeded: ' + window.rsxapp.page_data.ssr_preload.length + ' entries'); + } + // ───────────────────────────────────────────────────────────────────── // Register Component Classes with jqhtml Runtime // @@ -135,6 +148,12 @@ class Jqhtml_Integration { */ static async _on_framework_modules_init() { await jqhtml.boot(); + + // Clear any remaining SSR preload data after hydration completes + if (typeof jqhtml.clear_preload_data === 'function') { + jqhtml.clear_preload_data(); + } + Rsx.trigger('jqhtml_ready'); } } diff --git a/app/RSpade/Lib/Flash/Flash_Alert.php b/app/RSpade/Lib/Flash/Flash_Alert.php index 70b7bf66a..1b6c7d717 100644 --- a/app/RSpade/Lib/Flash/Flash_Alert.php +++ b/app/RSpade/Lib/Flash/Flash_Alert.php @@ -86,6 +86,13 @@ class Flash_Alert */ public static function get_pending_messages(): array { + // No session cookie = no session = no pending messages. + // Avoid calling get_session_id() which would create a session + // for anonymous visitors who have never triggered session activation. + if (empty($_COOKIE['rsx'])) { + return []; + } + $session_id = Session::get_session_id(); if ($session_id === null) { return []; diff --git a/app/RSpade/man/fpc.txt b/app/RSpade/man/fpc.txt new file mode 100755 index 000000000..a1cfcc5cf --- /dev/null +++ b/app/RSpade/man/fpc.txt @@ -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 diff --git a/app/RSpade/man/ssr.txt b/app/RSpade/man/ssr.txt new file mode 100755 index 000000000..95c93f59c --- /dev/null +++ b/app/RSpade/man/ssr.txt @@ -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) diff --git a/app/RSpade/man/ssr_fpc.txt b/app/RSpade/man/ssr_fpc.txt deleted file mode 100755 index 5142efede..000000000 --- a/app/RSpade/man/ssr_fpc.txt +++ /dev/null @@ -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 - 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": "...", - "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 - - -================================================================================ diff --git a/bin/fpc-proxy.js b/bin/fpc-proxy.js new file mode 100755 index 000000000..1713e475a --- /dev/null +++ b/bin/fpc-proxy.js @@ -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(); diff --git a/bin/ssr-server.js b/bin/ssr-server.js new file mode 100755 index 000000000..89bb6c127 --- /dev/null +++ b/bin/ssr-server.js @@ -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); +}); diff --git a/docs/CLAUDE.dist.md b/docs/CLAUDE.dist.md index 86fc0d0aa..4d0aed0d9 100644 --- a/docs/CLAUDE.dist.md +++ b/docs/CLAUDE.dist.md @@ -524,8 +524,9 @@ For mechanical thinkers who see structure, not visuals. Write `` not **Interpolation**: `<%= escaped %>` | `<%!= unescaped %>` | `<%br= newlines %>` | `<% javascript %>` **Conditional Attributes** `required="required"<% } %> />` +**Child Content**: `<%= content() %>` - Renders whatever the caller puts between opening/closing tags. Essential for wrapper components: `<%= content() %>` then `

Hello

`. Distinct from named `` — content() is the default/unnamed slot. **Inline Logic**: `<% this.handler = () => action(); %>` then `@click=this.handler` - No JS file needed for simple components -**Event Handlers**: `@click=this.method` (unquoted) - Methods defined inline or in companion .js +**Event Handlers**: `@click=this.method` (unquoted) - Methods defined inline or in companion .js. **Placement**: works on child HTML elements inside the template (`