#!/usr/bin/env node /** * SSR FPC (Full Page Cache) Generation Script * Generates static pre-rendered HTML cache using Playwright * * Usage: node generate-static-cache.js [options] * * Arguments: * route The route to generate cache for (e.g., /about) * * Options: * --timeout= Navigation timeout in milliseconds (default 30000ms) * --help Show this help message */ const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); // Parse command line arguments function parse_args() { const args = process.argv.slice(2); if (args.length === 0 || args.includes('--help')) { console.log('SSR FPC Generation - Generate static pre-rendered HTML cache'); console.log(''); console.log('Usage: node generate-static-cache.js [options]'); console.log(''); console.log('Arguments:'); console.log(' route The route to generate cache for (e.g., /about)'); console.log(''); console.log('Options:'); console.log(' --timeout= Navigation timeout in milliseconds (default 30000ms)'); console.log(' --help Show this help message'); process.exit(0); } const options = { route: null, timeout: 30000, }; for (const arg of args) { if (arg.startsWith('--timeout=')) { options.timeout = parseInt(arg.substring(10)); if (options.timeout < 30000) { console.error('Error: Timeout value is in milliseconds and must be no less than 30000 milliseconds (30 seconds)'); process.exit(1); } } else if (!arg.startsWith('--')) { options.route = arg; } } if (!options.route) { console.error('Error: Route argument is required'); process.exit(1); } // Ensure route starts with / if (!options.route.startsWith('/')) { options.route = '/' + options.route; } return options; } // Log error to file function log_error(url, error, details = {}) { const log_dir = path.join(process.cwd(), 'storage', 'logs'); const log_file = path.join(log_dir, 'ssr-fpc-errors.log'); const timestamp = new Date().toISOString(); const log_entry = { timestamp, url, error: error.message || String(error), stack: error.stack || null, ...details }; const log_line = JSON.stringify(log_entry) + '\n'; try { if (!fs.existsSync(log_dir)) { fs.mkdirSync(log_dir, { recursive: true }); } fs.appendFileSync(log_file, log_line); } catch (e) { console.error('Failed to write error log:', e.message); } } // Main execution (async () => { const options = parse_args(); const baseUrl = process.env.BASE_URL || 'http://localhost'; const fullUrl = baseUrl + options.route; let browser; let page; try { // Launch browser (always headless) browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const context = await browser.newContext({ ignoreHTTPSErrors: true }); page = await context.newPage(); // Set up headers for FPC generation request const extraHeaders = { 'X-RSpade-FPC-Client': '1', // Identifies this as FPC generation request }; // Use route interception to add headers await page.route('**/*', async (route, request) => { const url = request.url(); // Only add headers to local requests if (url.startsWith(baseUrl)) { await route.continue({ headers: { ...request.headers(), ...extraHeaders } }); } else { await route.continue(); } }); // Navigate to the route let response; try { response = await page.goto(fullUrl, { waitUntil: 'networkidle', timeout: options.timeout }); } catch (error) { log_error(fullUrl, error, { phase: 'navigation', timeout: options.timeout }); throw error; } if (!response) { const error = new Error('Navigation failed - no response'); log_error(fullUrl, error, { phase: 'navigation' }); throw error; } // Check for redirect (300-399 status codes) const status = response.status(); if (status >= 300 && status < 400) { // Redirect response - cache the redirect const redirect_location = response.headers()['location']; if (!redirect_location) { const error = new Error(`Redirect response (${status}) but no Location header found`); log_error(fullUrl, error, { phase: 'redirect_check', status, headers: response.headers() }); throw error; } // Output redirect response as JSON const result = { url: options.route, code: status, redirect: redirect_location, page_dom: null }; console.log(JSON.stringify(result)); await browser.close(); process.exit(0); } // Wait for RSX framework and jqhtml components to complete initialization try { await page.evaluate(() => { return new Promise((resolve) => { // Check if RSX framework with _debug_ready event is available if (window.Rsx && window.Rsx.on) { // Use Rsx._debug_ready event which fires after all jqhtml components complete lifecycle window.Rsx.on('_debug_ready', function() { resolve(); }); } else { // Fallback for non-RSX pages: wait for DOMContentLoaded + 200ms if (document.readyState === 'complete' || document.readyState === 'interactive') { setTimeout(function() { resolve(); }, 200); } else { document.addEventListener('DOMContentLoaded', function() { setTimeout(function() { resolve(); }, 200); }); } } }); }); } catch (error) { log_error(fullUrl, error, { phase: 'wait_for_ready', has_rsx: await page.evaluate(() => typeof window.Rsx !== 'undefined') }); throw error; } // Additional 10ms wait for final network settle await new Promise(resolve => setTimeout(resolve, 10)); // Get the fully rendered DOM let page_dom; try { page_dom = await page.content(); } catch (error) { log_error(fullUrl, error, { phase: 'get_dom' }); throw error; } // Output success response as JSON const result = { url: options.route, code: status, page_dom: page_dom, redirect: null }; console.log(JSON.stringify(result)); await browser.close(); process.exit(0); } catch (error) { if (browser) { await browser.close(); } // Log error if not already logged if (!error.logged) { log_error(fullUrl, error, { phase: 'unknown' }); } console.error('FATAL: SSR FPC generation failed'); console.error(error.message); if (error.stack) { console.error(error.stack); } process.exit(1); } })();