Fix bin/publish: use correct .env path for rspade_system Fix bin/publish script: prevent grep exit code 1 from terminating script 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
261 lines
8.0 KiB
JavaScript
Executable File
261 lines
8.0 KiB
JavaScript
Executable File
#!/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 <route> [options]
|
|
*
|
|
* Arguments:
|
|
* route The route to generate cache for (e.g., /about)
|
|
*
|
|
* Options:
|
|
* --timeout=<ms> 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 <route> [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=<ms> 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);
|
|
}
|
|
})();
|