// lib/snapshot.js — headless-chromium homepage screenshots of deployed apps. // One chromium at a time (module-level promise chain); duplicate requests for // a project that is already queued/running are skipped. Never throws — all // errors are caught and logged to the console. import fs from 'node:fs'; import path from 'node:path'; import { config, NAME_RE } from './config.js'; import { runCmd } from './paas.js'; // Domain is embedded in chromium args — must be a plain hostname, nothing else. const SNAP_DOMAIN_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/; let chain = Promise.resolve(); const inFlight = new Set(); export function snapshotPath(projectName) { if (!NAME_RE.test(projectName)) throw new Error(`invalid app name: ${projectName}`); return path.join(config.snapshotDir, `${projectName}.png`); } // Fire-and-forget friendly: resolves true when a fresh snapshot was written, // false when skipped or failed. Never rejects. export function takeSnapshot(projectName, primaryDomain) { try { if (!NAME_RE.test(String(projectName || ''))) return Promise.resolve(false); const domain = String(primaryDomain || '').trim().toLowerCase(); if (!SNAP_DOMAIN_RE.test(domain)) return Promise.resolve(false); if (inFlight.has(projectName)) return Promise.resolve(false); inFlight.add(projectName); const job = chain .then(() => run(projectName, domain)) .catch((err) => { console.error(`[snapshot] ${projectName}: ${(err && err.message) || err}`); return false; }) .finally(() => inFlight.delete(projectName)); chain = job; return job; } catch (err) { console.error(`[snapshot] ${projectName}: ${(err && err.message) || err}`); return Promise.resolve(false); } } async function run(projectName, domain) { fs.mkdirSync(config.snapshotDir, { recursive: true }); const outFile = snapshotPath(projectName); const args = [ '--headless=new', '--no-sandbox', // the render is pinned to 127.0.0.1 — cert validity is irrelevant for a // thumbnail, and freshly created apps have no certificate yet at all '--ignore-certificate-errors', '--disable-gpu', '--hide-scrollbars', '--window-size=1366,900', `--screenshot=${outFile}`, `--host-resolver-rules=MAP ${domain} 127.0.0.1`, '--virtual-time-budget=15000', `https://${domain}/`, ]; // niced: a snapshot render must never compete with request serving const res = await runCmd('nice', ['-n', '19', config.chromiumBin, ...args], { timeout: 60000 }); if (res.code !== 0) { const detail = res.timedOut ? 'timed out after 60s' : `exited with code ${res.code}`; console.error(`[snapshot] ${projectName}: chromium ${detail}: ${res.stderr.trim().slice(-300)}`); return false; } return true; }