import { readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { preview } from 'vite';
import puppeteer from 'puppeteer';
import { discoverAllRoutes } from '../pages/discoverRoutes.js';
const allRoutes = discoverAllRoutes();
const DIST = 'dist/client';
const CONCURRENCY = Number(process.env.PRERENDER_CONCURRENCY) || 6;
const PAGE_TIMEOUT = 30000;
const RENDER_SETTLE_MS = 300;
const SELECTOR_TIMEOUT = 8000;
const RETRY =
process.env.PRERENDER_RETRY === '1' || process.argv.includes('--retry');
function routeToFile(route) {
if (route === '/') return join(DIST, 'index.html');
return join(DIST, route.replace(/^\//, ''), 'index.html');
}
function fmtMs(ms) {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function fmtSec(ms) {
const s = ms / 1000;
if (s < 60) return `${s.toFixed(2)}s`;
const m = Math.floor(s / 60);
return `${m}m ${(s - m * 60).toFixed(1)}s`;
}
async function crawlOne(browser, baseUrl, route) {
const t0 = Date.now();
const target = baseUrl + route;
const file = routeToFile(route);
if (!existsSync(file))
return { route, skipped: 'no prerendered shell', ms: Date.now() - t0 };
const page = await browser.newPage();
try {
const consoleErrors = [];
// Images, fonts and media are aborted below to keep the crawl fast. The
// browser reports every one of those as a failed load, so counting them
// would just be counting our own optimisation — track what we blocked and
// drop those messages, or the tally is pure noise that swings run to run.
const blocked = new Set();
page.on('console', (m) => {
if (m.type() !== 'error') return;
const url = m.location()?.url;
if (url && blocked.has(url)) return;
consoleErrors.push(m.text());
});
page.on('pageerror', (e) => consoleErrors.push(`page error: ${e.message}`));
await page.setRequestInterception(true);
page.on('request', (req) => {
const t = req.resourceType();
if (t === 'image' || t === 'font' || t === 'media') {
blocked.add(req.url());
return req.abort();
}
req.continue();
});
await page.goto(target, {
waitUntil: 'domcontentloaded',
timeout: PAGE_TIMEOUT,
});
try {
await page.waitForSelector('#react-root > *', {
timeout: SELECTOR_TIMEOUT,
});
} catch {
return {
route,
skipped: `no children rendered within ${SELECTOR_TIMEOUT}ms`,
ms: Date.now() - t0,
};
}
await new Promise((r) => setTimeout(r, RENDER_SETTLE_MS));
const rendered = await page.evaluate(() => {
const root = document.getElementById('react-root');
return root ? root.innerHTML : '';
});
if (!rendered)
return { route, skipped: 'empty root after render', ms: Date.now() - t0 };
const shell = await readFile(file, 'utf8');
const replaced = shell.replace(
/
<\/div>/,
`
${rendered}
`,
);
if (replaced === shell) {
return {
route,
skipped: 'root placeholder not found',
ms: Date.now() - t0,
};
}
await writeFile(file, replaced, 'utf8');
return {
route,
bytes: rendered.length,
errors: consoleErrors.length,
messages: consoleErrors,
ms: Date.now() - t0,
};
} catch (e) {
return { route, error: e.message, ms: Date.now() - t0 };
} finally {
await page.close().catch(() => {});
}
}
async function warmUp(baseUrl) {
const deadline = Date.now() + 10000;
while (Date.now() < deadline) {
try {
const res = await fetch(baseUrl + '/');
if (res.ok) return Date.now();
} catch {}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error('preview server did not respond within 10s');
}
function logRoute(r) {
const t = fmtMs(r.ms ?? 0);
if (r.error) {
console.log(` x ${r.route}: ${r.error} (${t})`);
} else if (r.skipped) {
console.log(` - ${r.route}: ${r.skipped} (${t})`);
} else {
console.log(
` . ${r.route} (${r.bytes} bytes, ${r.errors} console errors, ${t})`,
);
}
}
async function main() {
const tStart = Date.now();
console.log(
`prerender-crawl: ${allRoutes.length} routes, concurrency ${CONCURRENCY}`,
);
const server = await preview({
preview: { port: 4287, strictPort: true, host: '127.0.0.1' },
});
const url =
server.resolvedUrls?.local?.[0]?.replace(/\/$/, '') ||
'http://127.0.0.1:4287';
console.log(`preview up at ${url}`);
const warmStart = Date.now();
await warmUp(url);
console.log(`warm-up: preview responded in ${fmtMs(Date.now() - warmStart)}`);
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const results = [];
const queue = [...allRoutes];
const workers = Array.from({ length: CONCURRENCY }, async () => {
while (queue.length) {
const route = queue.shift();
const r = await crawlOne(browser, url, route);
results.push(r);
logRoute(r);
}
});
await Promise.all(workers);
if (RETRY) {
const flaky = results
.filter((r) => r.skipped && /within \d+ms|empty root/.test(r.skipped))
.map((r) => r.route);
if (flaky.length) {
console.log(
`\nprerender-crawl: retrying ${flaky.length} flaky route(s) sequentially...`,
);
const retryResults = new Map();
for (const route of flaky) {
const r = await crawlOne(browser, url, route);
retryResults.set(route, r);
logRoute({ ...r, route: `${route} [retry]` });
}
for (let i = 0; i < results.length; i++) {
const r = results[i];
if (retryResults.has(r.route)) {
const next = retryResults.get(r.route);
if (next.bytes || next.error) results[i] = next;
}
}
}
}
await browser.close();
await new Promise((resolve) => server.httpServer.close(resolve));
const total = results.length;
const ok = results.filter((r) => r.bytes).length;
const errs = results.filter((r) => r.error).length;
const skipped = results.filter((r) => r.skipped).length;
const routeTimes = results.map((r) => r.ms || 0);
const avgMs = routeTimes.length
? routeTimes.reduce((a, b) => a + b, 0) / routeTimes.length
: 0;
const maxMs = routeTimes.length ? Math.max(...routeTimes) : 0;
const pct = total ? ((ok / total) * 100).toFixed(1) : '0.0';
const elapsed = Date.now() - tStart;
console.log(
`\nprerender-crawl: ${ok}/${total} rendered (${pct}% success), ${skipped} skipped, ${errs} errored`,
);
console.log(
`prerender-crawl: per-route avg ${fmtMs(Math.round(avgMs))}, max ${fmtMs(maxMs)}`,
);
console.log(`prerender-crawl: total elapsed ${fmtSec(elapsed)}`);
// Say what the errors actually were. A bare count sends anyone reading this
// log guessing, which is exactly how the blocked-resource noise above went
// unexplained for so long.
const byMessage = new Map();
for (const r of results) {
for (const m of r.messages || []) {
const key = m.slice(0, 140);
const hit = byMessage.get(key) || { n: 0, routes: [] };
hit.n += 1;
if (hit.routes.length < 5 && !hit.routes.includes(r.route)) {
hit.routes.push(r.route);
}
byMessage.set(key, hit);
}
}
const noisy = results.filter((r) => (r.errors || 0) > 0).length;
if (byMessage.size) {
console.log(`
prerender-crawl: ${noisy} route(s) logged console errors`);
[...byMessage.entries()]
.sort((a, b) => b[1].n - a[1].n)
.slice(0, 12)
.forEach(([msg, hit]) => {
console.log(` ${String(hit.n).padStart(4)}x ${msg}`);
console.log(` on ${hit.routes.join(', ')}`);
});
} else {
console.log('prerender-crawl: no console errors');
}
try {
const home = await readFile(join(DIST, 'index.html'), 'utf8');
await writeFile(join(DIST, '404.html'), home, 'utf8');
} catch {}
// Exit explicitly instead of waiting for the event loop to drain. Puppeteer
// can leave an orphaned renderer or crashpad handler holding the pipe it
// inherited, and build.mjs then blocks forever on a crawl that has already
// written every page. Flush stdout first so the summary above is not cut off.
const code = errs > 0 ? 1 : 0;
await new Promise((resolve) => process.stdout.write('', resolve));
process.exit(code);
}
await main();