Skip to content

Commit aaab68b

Browse files
committed
build(ssg): per-phase timings, warm-up ping, success ratio
Wrap the three build phases in scripts/build.mjs so we get per-step and total elapsed time plus a timing summary. Add a preview server warm-up that waits for /to respond 200 before workers start — prevents the cold-start stampede that was skipping ~400/460 routes. Crawler now prints per-route ms, success ratio (X/Y N.N%), average and max per-route times in the final summary. post-build also prints its elapsed ms.
1 parent 2f78dc2 commit aaab68b

4 files changed

Lines changed: 131 additions & 22 deletions

File tree

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@
4848
"prestart": "npm run pregenerate",
4949
"start": "vite",
5050
"prebuild": "npm run pregenerate",
51-
"build": "vite build && node scripts/post-build.mjs && node scripts/prerender-crawl.mjs",
52-
"build:fast": "vite build && node scripts/post-build.mjs",
53-
"build:retry": "vite build && node scripts/post-build.mjs && node scripts/prerender-crawl.mjs --retry",
51+
"build": "node scripts/build.mjs",
52+
"build:fast": "node scripts/build.mjs --fast",
53+
"build:retry": "node scripts/build.mjs --retry",
5454
"deploy:fast": "npm run build:fast && gh-pages -d dist/client -b gh-pages --no-history",
5555
"deploy:retry": "npm run build:retry && gh-pages -d dist/client -b gh-pages --no-history",
5656
"preview": "vite preview",

scripts/build.mjs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { spawn } from 'node:child_process';
2+
3+
const flags = new Set(process.argv.slice(2));
4+
const FAST = flags.has('--fast');
5+
const RETRY = flags.has('--retry');
6+
7+
const steps = [
8+
{ name: 'vite build', cmd: 'vite', args: ['build'] },
9+
{ name: 'post-build', cmd: 'node', args: ['scripts/post-build.mjs'] },
10+
];
11+
if (!FAST) {
12+
steps.push({
13+
name: 'prerender-crawl',
14+
cmd: 'node',
15+
args: ['scripts/prerender-crawl.mjs', ...(RETRY ? ['--retry'] : [])],
16+
});
17+
}
18+
19+
function run(step) {
20+
return new Promise((resolve, reject) => {
21+
const isWin = process.platform === 'win32';
22+
const child = spawn(step.cmd, step.args, {
23+
stdio: 'inherit',
24+
shell: isWin,
25+
});
26+
child.on('close', (code) => {
27+
if (code === 0) resolve();
28+
else reject(new Error(`${step.name} exited with code ${code}`));
29+
});
30+
child.on('error', reject);
31+
});
32+
}
33+
34+
function fmtSec(ms) {
35+
const s = ms / 1000;
36+
if (s < 60) return `${s.toFixed(2)}s`;
37+
const m = Math.floor(s / 60);
38+
return `${m}m ${(s - m * 60).toFixed(1)}s`;
39+
}
40+
41+
const globalStart = Date.now();
42+
const timings = [];
43+
44+
for (const step of steps) {
45+
const s = Date.now();
46+
try {
47+
await run(step);
48+
} catch (err) {
49+
const elapsed = Date.now() - s;
50+
timings.push({ name: step.name, ms: elapsed, failed: true });
51+
console.error(`\n[build] ${step.name} failed after ${fmtSec(elapsed)}`);
52+
console.error(`[build] total elapsed: ${fmtSec(Date.now() - globalStart)}`);
53+
process.exit(1);
54+
}
55+
const elapsed = Date.now() - s;
56+
timings.push({ name: step.name, ms: elapsed });
57+
console.log(`[build] ${step.name}: ${fmtSec(elapsed)}`);
58+
}
59+
60+
const total = Date.now() - globalStart;
61+
console.log(`\n[build] ── summary ──`);
62+
for (const t of timings) {
63+
const pct = ((t.ms / total) * 100).toFixed(0);
64+
console.log(` ${t.name.padEnd(16)} ${fmtSec(t.ms).padStart(10)} (${pct}%)`);
65+
}
66+
console.log(` ${'total'.padEnd(16)} ${fmtSec(total).padStart(10)}`);

scripts/post-build.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { copyFile } from 'node:fs/promises';
22

3+
const start = Date.now();
34
await copyFile('dist/client/index.html', 'dist/client/404.html');
4-
console.log('post-build: copied dist/client/index.html -> dist/client/404.html');
5+
const elapsed = Date.now() - start;
6+
console.log(`post-build: copied dist/client/index.html -> dist/client/404.html (${elapsed}ms)`);

scripts/prerender-crawl.mjs

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,23 @@ function routeToFile(route) {
1919
return join(DIST, route.replace(/^\//, ''), 'index.html');
2020
}
2121

22+
function fmtMs(ms) {
23+
if (ms < 1000) return `${ms}ms`;
24+
return `${(ms / 1000).toFixed(2)}s`;
25+
}
26+
27+
function fmtSec(ms) {
28+
const s = ms / 1000;
29+
if (s < 60) return `${s.toFixed(2)}s`;
30+
const m = Math.floor(s / 60);
31+
return `${m}m ${(s - m * 60).toFixed(1)}s`;
32+
}
33+
2234
async function crawlOne(browser, baseUrl, route) {
35+
const t0 = Date.now();
2336
const target = baseUrl + route;
2437
const file = routeToFile(route);
25-
if (!existsSync(file)) return { route, skipped: 'no prerendered shell' };
38+
if (!existsSync(file)) return { route, skipped: 'no prerendered shell', ms: Date.now() - t0 };
2639

2740
const page = await browser.newPage();
2841
try {
@@ -40,7 +53,7 @@ async function crawlOne(browser, baseUrl, route) {
4053
try {
4154
await page.waitForSelector('#react-root > *', { timeout: SELECTOR_TIMEOUT });
4255
} catch {
43-
return { route, skipped: `no children rendered within ${SELECTOR_TIMEOUT}ms` };
56+
return { route, skipped: `no children rendered within ${SELECTOR_TIMEOUT}ms`, ms: Date.now() - t0 };
4457
}
4558
await new Promise((r) => setTimeout(r, RENDER_SETTLE_MS));
4659

@@ -49,26 +62,50 @@ async function crawlOne(browser, baseUrl, route) {
4962
return root ? root.innerHTML : '';
5063
});
5164

52-
if (!rendered) return { route, skipped: 'empty root after render' };
65+
if (!rendered) return { route, skipped: 'empty root after render', ms: Date.now() - t0 };
5366

5467
const shell = await readFile(file, 'utf8');
5568
const replaced = shell.replace(
5669
/<div id="react-root"><\/div>/,
5770
`<div id="react-root">${rendered}</div>`
5871
);
5972
if (replaced === shell) {
60-
return { route, skipped: 'root placeholder not found' };
73+
return { route, skipped: 'root placeholder not found', ms: Date.now() - t0 };
6174
}
6275
await writeFile(file, replaced, 'utf8');
63-
return { route, bytes: rendered.length, errors: consoleErrors.length };
76+
return { route, bytes: rendered.length, errors: consoleErrors.length, ms: Date.now() - t0 };
6477
} catch (e) {
65-
return { route, error: e.message };
78+
return { route, error: e.message, ms: Date.now() - t0 };
6679
} finally {
6780
await page.close().catch(() => {});
6881
}
6982
}
7083

84+
async function warmUp(baseUrl) {
85+
const deadline = Date.now() + 10000;
86+
while (Date.now() < deadline) {
87+
try {
88+
const res = await fetch(baseUrl + '/');
89+
if (res.ok) return Date.now();
90+
} catch {}
91+
await new Promise((r) => setTimeout(r, 200));
92+
}
93+
throw new Error('preview server did not respond within 10s');
94+
}
95+
96+
function logRoute(r) {
97+
const t = fmtMs(r.ms ?? 0);
98+
if (r.error) {
99+
console.log(` x ${r.route}: ${r.error} (${t})`);
100+
} else if (r.skipped) {
101+
console.log(` - ${r.route}: ${r.skipped} (${t})`);
102+
} else {
103+
console.log(` . ${r.route} (${r.bytes} bytes, ${r.errors} console errors, ${t})`);
104+
}
105+
}
106+
71107
async function main() {
108+
const tStart = Date.now();
72109
console.log(`prerender-crawl: ${allRoutes.length} routes, concurrency ${CONCURRENCY}`);
73110

74111
const server = await preview({
@@ -77,6 +114,10 @@ async function main() {
77114
const url = server.resolvedUrls?.local?.[0]?.replace(/\/$/, '') || 'http://127.0.0.1:4287';
78115
console.log(`preview up at ${url}`);
79116

117+
const warmStart = Date.now();
118+
await warmUp(url);
119+
console.log(`warm-up: preview responded in ${fmtMs(Date.now() - warmStart)}`);
120+
80121
const browser = await puppeteer.launch({
81122
args: ['--no-sandbox', '--disable-setuid-sandbox'],
82123
});
@@ -88,13 +129,7 @@ async function main() {
88129
const route = queue.shift();
89130
const r = await crawlOne(browser, url, route);
90131
results.push(r);
91-
if (r.error) {
92-
console.log(` x ${route}: ${r.error}`);
93-
} else if (r.skipped) {
94-
console.log(` - ${route}: ${r.skipped}`);
95-
} else {
96-
console.log(` . ${route} (${r.bytes} bytes, ${r.errors} console errors)`);
97-
}
132+
logRoute(r);
98133
}
99134
});
100135
await Promise.all(workers);
@@ -109,9 +144,7 @@ async function main() {
109144
for (const route of flaky) {
110145
const r = await crawlOne(browser, url, route);
111146
retryResults.set(route, r);
112-
if (r.bytes) console.log(` . ${route} (retry: ${r.bytes} bytes, ${r.errors} console errors)`);
113-
else if (r.skipped) console.log(` - ${route}: retry ${r.skipped}`);
114-
else if (r.error) console.log(` x ${route}: retry ${r.error}`);
147+
logRoute({ ...r, route: `${route} [retry]` });
115148
}
116149
for (let i = 0; i < results.length; i++) {
117150
const r = results[i];
@@ -126,17 +159,25 @@ async function main() {
126159
await browser.close();
127160
await new Promise((resolve) => server.httpServer.close(resolve));
128161

162+
const total = results.length;
129163
const ok = results.filter((r) => r.bytes).length;
130164
const errs = results.filter((r) => r.error).length;
131165
const skipped = results.filter((r) => r.skipped).length;
132-
console.log(`\nprerender-crawl: ${ok} rendered, ${skipped} skipped, ${errs} errored`);
166+
const routeTimes = results.map((r) => r.ms || 0);
167+
const avgMs = routeTimes.length ? routeTimes.reduce((a, b) => a + b, 0) / routeTimes.length : 0;
168+
const maxMs = routeTimes.length ? Math.max(...routeTimes) : 0;
169+
const pct = total ? ((ok / total) * 100).toFixed(1) : '0.0';
170+
const elapsed = Date.now() - tStart;
171+
172+
console.log(`\nprerender-crawl: ${ok}/${total} rendered (${pct}% success), ${skipped} skipped, ${errs} errored`);
173+
console.log(`prerender-crawl: per-route avg ${fmtMs(Math.round(avgMs))}, max ${fmtMs(maxMs)}`);
174+
console.log(`prerender-crawl: total elapsed ${fmtSec(elapsed)}`);
133175

134176
try {
135177
const home = await readFile(join(DIST, 'index.html'), 'utf8');
136178
await writeFile(join(DIST, '404.html'), home, 'utf8');
137179
} catch {}
138180

139-
// Skipped routes keep their empty-shell HTML + SPA fallback — not a build failure.
140181
if (errs > 0) process.exit(1);
141182
}
142183

0 commit comments

Comments
 (0)