#!/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();