Skip to content

Commit 8230b13

Browse files
fix(docs): serialize prerender so large pages aren't truncated (#44)
The SPA prerender defaults to os.cpus().length concurrency. Several routes race through the in-process render server at once, and under that load a large response body stream is cut at the first ~64KiB chunk before res.text() drains it. Any page over 64KiB (e.g. /islands/charts, /reference/manifest) is then written truncated, losing its trailing hydration <script> and shipping as dead, non-interactive HTML. Which page loses the race varies per build and worsens on high-core CD runners, so it slipped past local builds into production. Set prerender.concurrency to 1 to remove the race, and add a postbuild guard that fails the build if any prerendered page is missing its closing </html>.
1 parent b05e455 commit 8230b13

2 files changed

Lines changed: 21 additions & 2 deletions

File tree

apps/docs/scripts/postbuild.mjs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// static-asset serving expects `index.html` both for `/` and as the single-page-app
33
// fallback (`not_found_handling: "single-page-application"`). Materialize the shell as
44
// index.html so the home route and client-side navigation both resolve on Workers.
5-
import { copyFileSync, existsSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5+
import { copyFileSync, existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
66
import { dirname, relative, resolve } from "node:path";
77
import { fileURLToPath } from "node:url";
88

@@ -54,7 +54,19 @@ function toLocation(indexHtmlPath) {
5454
return `${SITE_ORIGIN}/${route}`;
5555
}
5656

57-
const locations = findIndexHtmlPaths(publicDir).map(toLocation).toSorted();
57+
const indexHtmlPaths = findIndexHtmlPaths(publicDir);
58+
59+
// Guard against silently truncated prerenders: a complete page ends with `</html>`, and the
60+
// hydration-bootstrap <script> sits just before it, so this one check proves the page (and
61+
// its ability to hydrate) shipped whole. Fail the build rather than deploy dead HTML.
62+
const truncated = indexHtmlPaths.filter((p) => !readFileSync(p, "utf8").trimEnd().endsWith("</html>"));
63+
if (truncated.length > 0) {
64+
console.error(`postbuild: ${truncated.length} prerendered page(s) are truncated (missing </html>):`);
65+
for (const p of truncated) console.error(` ✘ ${relative(publicDir, p)}`);
66+
process.exit(1);
67+
}
68+
69+
const locations = indexHtmlPaths.map(toLocation).toSorted();
5870
const urls = locations.map((loc) => ` <url><loc>${loc}</loc></url>`).join("\n");
5971
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
6072
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">

apps/docs/vite.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ export default defineConfig({
4747
crawlLinks: true,
4848
},
4949
},
50+
// Prerender each route one at a time. The default concurrency (os.cpus().length)
51+
// races several routes through the in-process render server at once, and under that
52+
// load a large response's body stream gets cut at the first ~64KiB chunk before
53+
// `res.text()` drains it — so a >64KiB page (e.g. islands/charts) is silently written
54+
// truncated, losing its trailing hydration <script> and shipping as dead HTML. Which
55+
// page loses the race varies per build. Serial prerender removes the race.
56+
prerender: { concurrency: 1 },
5057
pages: [
5158
{ path: "/" },
5259
...docPaths.map((path) => ({ path })),

0 commit comments

Comments
 (0)