feat: static pre-rendering at build time#274
feat: static pre-rendering at build time#274NathanDrake2406 wants to merge 46 commits intocloudflare:mainfrom
Conversation
commit: |
Add runStaticExport() orchestrator that connects the existing staticExportPages/staticExportApp functions to the CLI build pipeline. When next.config has output: 'export', vinext build now: 1. Runs the Vite build 2. Starts a temporary dev server 3. Renders all static pages to out/ 4. Prints a summary Also adds 'vinext start' rejection for export builds, matching Next.js behavior (ported from test/e2e/app-dir-export/test/start.test.ts). Phase 1 of cloudflare#9
Add Phase 2 orchestrator that pre-renders static pages after a production build: 1. Starts a temporary prod server in-process 2. Detects static routes via Vite dev server module inspection 3. Fetches each static route and writes HTML to dist/server/pages/ For Pages Router: skips pages with getServerSideProps, expands dynamic routes via getStaticPaths (fallback: false only). For App Router: skips force-dynamic pages, expands dynamic routes via generateStaticParams with parent param resolution. Phase 2 of cloudflare#9
Add resolvePrerenderedHtml() helper and pre-render check to both App Router and Pages Router production servers. Pre-rendered files in dist/server/pages/ are served directly with text/html before falling back to SSR. Includes path traversal protection and supports both /page.html and /page/index.html patterns for trailingSlash compatibility. Phase 2 of cloudflare#9
After a non-export build completes, automatically pre-render static pages to dist/server/pages/. The prod server serves these directly without SSR on first request. Phase 2 of cloudflare#9
1. Remove App Router pre-rendered HTML shortcut — was bypassing RSC streaming and middleware/auth pipeline 2. Move Pages Router pre-rendered check after middleware/basePath/ redirects/rewrites pipeline (step 7b instead of 1b) 3. Skip ISR pages (revalidate != false) in collectStaticRoutes() to prevent freezing dynamic content as static HTML 4. basePath handling covered by fix cloudflare#2 (uses resolvedPathname) 5. Temp Vite servers now check for project vite.config and use it when present, so user plugins/aliases are available 6. vinext start guard now checks config.output directly instead of relying on out/ directory existence heuristic
… to warning Dynamic routes without generateStaticParams (App Router) or getStaticPaths (Pages Router) in output: 'export' mode now produce a warning and skip the route instead of failing the build. This enables legitimate use cases like CMS apps with no published content and SPA-style client-rendered dynamic routes. Addresses vercel/next.js#61213 and vercel/next.js#55393.
Must-fixes: - Update stale file header comments (warning, not error) - Sanitize getOutputPath() against path traversal from user params - Fix revalidate filtering: only skip revalidate=0 (force-dynamic), not all ISR pages — they should be pre-rendered - Fix clearTimeout leak on fetch failure in prerenderStaticPages - Consume response body on non-ok fetch in staticExportApp - Fix revalidate:0 handling in Pages Router collectStaticRoutes (0 is falsy, so the old check didn't catch it) Should-fixes: - Wrap renderStaticPage in try/finally to clear SSR context on error - Extract createTempViteServer() helper (was duplicated 2x) - Extract expandDynamicAppRoute() helper (was duplicated with diverging behavior between staticExportApp and collectStaticRoutes) - Remove unnecessary server.listen() in collectStaticRoutes — only ssrLoadModule is needed, not an HTTP listener - Add Pages Router test for warning on missing getStaticPaths
cd25163 to
ce830ad
Compare
- collectStaticRoutes now returns { urls, skipped } instead of just
URLs — every skipped route includes a reason (force-dynamic,
revalidate: 0, load failure, getServerSideProps, etc.)
- Skipped routes surface in PrerenderResult.skipped for CLI output
- Type createTempViteServer config as vite.InlineConfig instead of
Record<string, unknown>
- Skip App Router builds in prerenderStaticPages() — pre-rendered HTML
files were written to dist/server/pages/ but never served (the App
Router prod server delegates entirely to the RSC handler)
- Fix getStaticProps({}) probe to pass { params: {} } — prevents crash
when getStaticProps destructures context.params
- Replace sync readFileSync with async fs.promises.readFile in the
per-request pre-rendered HTML handler
- Move pagesPrerenderedDir computation and directory existence check to
server startup (was recomputed on every request)
- Remove stale TDD comments claiming functions don't exist yet, fix
JSDoc describing nonexistent subprocess fallback
Keep createTempViteServer (from this PR) and adopt upstream's ValidFileMatcher-based findFileWithExtensions signature. Fix prerendered page headers type to match widened middlewareHeaders.
|
/bigbonk review please |
There was a problem hiding this comment.
Review: Static pre-rendering at build time
Solid feature implementation overall. The architecture is well-structured: runStaticExport() as the orchestrator for output: "export", prerenderStaticPages() for selective pre-rendering in normal builds, and the prod server integration to serve pre-rendered HTML. The refactoring of renderStaticPage to use try/finally for SSR context cleanup and the extraction of expandDynamicAppRoute are good improvements.
I found one security issue that should be fixed, plus several correctness and robustness concerns.
Security
resolvePrerenderedHtmlhas a path prefix confusion bug —startsWith(resolvedDir)without a trailing separator means a directory namedpages-eviladjacent topageswould pass the check (see inline comment).
Correctness concerns
- The
getOutputPathtraversal guard is dead code —path.posix.normalizeresolves..segments, so theincludes('..')check after normalization can never be true. collectStaticRoutescallsgetStaticProps({ params: {} })on every non-dynamic page during route collection, purely to checkrevalidate. This has side effects (database queries, API calls) and runs with empty params which may not match the page's expectations.prerenderStaticPagesspins up two servers (a Vite dev server for route collection + a prod server for fetching). The dev server usesssrLoadModuleagainst source files while the prod server uses the built output — if there's any mismatch between source and built state, the route list will be wrong.
Test quality
- Tests are well-structured but the prerender tests depend on
hasServerEntry(a pre-existing build artifact). If the fixture hasn't been built, all 5 prod server tests silently skip viait.skipIf. This makes CI green even when the feature is untestable.
See inline comments for specific suggestions.
| const resolvedDir = path.resolve(dir); | ||
|
|
||
| const directPath = path.join(dir, `${normalized}.html`); | ||
| if (path.resolve(directPath).startsWith(resolvedDir) && fs.existsSync(directPath) && fs.statSync(directPath).isFile()) { |
There was a problem hiding this comment.
Bug (security): The startsWith check is vulnerable to prefix confusion. If the pre-rendered directory is /dist/server/pages, then a resolved path like /dist/server/pages-evil/hack.html will pass the startsWith check because the string /dist/server/pages-evil/... starts with /dist/server/pages.
The fix is to append path.sep (or /) to the resolved directory before the prefix check:
| if (path.resolve(directPath).startsWith(resolvedDir) && fs.existsSync(directPath) && fs.statSync(directPath).isFile()) { | |
| if (path.resolve(directPath).startsWith(resolvedDir + path.sep) && fs.existsSync(directPath) && fs.statSync(directPath).isFile()) { |
Same issue on line 306. In practice this is low-risk since the pathname comes from the URL which is already normalized upstream, but it's a defense-in-depth fix that should be applied.
| } | ||
|
|
||
| const indexPath = path.join(dir, normalized, "index.html"); | ||
| if (path.resolve(indexPath).startsWith(resolvedDir) && fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) { |
There was a problem hiding this comment.
Same prefix confusion bug as line 301:
| if (path.resolve(indexPath).startsWith(resolvedDir) && fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) { | |
| if (path.resolve(indexPath).startsWith(resolvedDir + path.sep) && fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) { |
| const normalized = path.posix.normalize(urlPath); | ||
| if (normalized.includes("..")) { | ||
| throw new Error(`Route path "${urlPath}" contains path traversal segments`); |
There was a problem hiding this comment.
This traversal guard is dead code. path.posix.normalize resolves .. segments, so after normalization, normalized will never contain ... For example, /foo/../../etc/passwd normalizes to /etc/passwd.
Either:
- Check before normalizing:
if (urlPath.includes('..'))— but this is overly broad and rejects legitimate paths. - Check the resolved output path against the
outDirboundary (which is the correct approach, but would need theoutDirpassed in). - Remove the dead check and add the boundary check at the write site in the callers.
Since the urlPath values come from route patterns and user-defined getStaticPaths/generateStaticParams, option 2 (checking fullPath.startsWith(resolvedOutDir + path.sep) at the write site) would be the most robust defense.
| if (typeof pageModule.getStaticProps === "function") { | ||
| try { | ||
| const propsResult = await pageModule.getStaticProps({ params: {} }); |
There was a problem hiding this comment.
Calling getStaticProps({ params: {} }) on every non-dynamic page during route collection is a side-effect concern. This function may make database queries, API calls, or other I/O — and it's being called with empty params purely to inspect the revalidate value. For dynamic routes with required params, this will likely throw (which the catch handles, but it means those routes are incorrectly skipped).
Consider a lighter-weight approach:
- Check for
export const revalidate = 0as a module-level export (similar to howdynamicis checked on line 1085) instead of executing the function. - Or skip the
getStaticPropsrevalidate check entirely for route collection — onlygetServerSidePropsandforce-dynamicare definitive signals that a page can't be pre-rendered. Pages withrevalidate: 0ingetStaticPropsare relatively rare.
|
|
||
| // Collect static routes using a temporary Vite dev server | ||
| const collected = await collectStaticRoutes(root, false); | ||
| result.skipped.push(...collected.skipped); | ||
|
|
||
| if (collected.urls.length === 0) { | ||
| result.warnings.push("No static routes found — nothing to pre-render"); | ||
| return result; | ||
| } | ||
|
|
||
| const staticUrls = collected.urls; | ||
|
|
||
| // Start temp production server in-process | ||
| const { startProdServer } = await import("../server/prod-server.js"); | ||
| const server = await startProdServer({ | ||
| port: 0, // Random available port | ||
| host: "127.0.0.1", | ||
| outDir: distDir, | ||
| }); | ||
| const addr = server.address() as import("node:net").AddressInfo; | ||
| const port = addr.port; |
There was a problem hiding this comment.
This function spins up two servers: a Vite dev server (via collectStaticRoutes on line 982) for route discovery, plus a production server (line 994) for HTML fetching. This is:
- Slow — two full server startups during build.
- Fragile — the dev server inspects source files while the prod server uses built output. If the source and build are out of sync (e.g., user edited a file after build), the route list won't match reality.
A more robust approach would be to either:
- Use only the prod server for both route detection and fetching (inspect the built server entry's route table), or
- Embed the list of static routes into the build output during the Vite build step so it's available without spinning up a dev server.
This doesn't block merging but is worth tracking as a follow-up for build performance.
| // ── Static export (output: "export") ────────────────────────── | ||
| const { loadNextConfig, resolveNextConfig } = await import( | ||
| /* @vite-ignore */ "./config/next-config.js" | ||
| ); | ||
| const rawConfig = await loadNextConfig(process.cwd()); | ||
| const resolvedConfig = await resolveNextConfig(rawConfig); |
There was a problem hiding this comment.
The config is loaded and resolved here, but runStaticExport (line 287) also loads and resolves the config internally. This means the config is parsed twice. Consider either:
- Passing the already-resolved config to
runStaticExport, or - Having the CLI only check
resolvedConfig.outputand letting the orchestrator handle everything else.
Also, if the config file has side effects or is expensive to load, double-loading could be a problem.
packages/vinext/src/cli.ts
Outdated
| const { loadNextConfig, resolveNextConfig } = await import( | ||
| /* @vite-ignore */ "./config/next-config.js" | ||
| ); | ||
| const startRawConfig = await loadNextConfig(process.cwd()); | ||
| const startResolvedConfig = await resolveNextConfig(startRawConfig); |
There was a problem hiding this comment.
Same double-loading issue as in buildApp(). The config is loaded here to check output: "export", then startProdServer will load its own config from the build output. Not a correctness issue but wasteful.
Also, the variable names startRawConfig / startResolvedConfig are a bit unusual. Since this is in its own function scope, rawConfig / resolvedConfig would be fine (no collision).
tests/build-prerender.test.ts
Outdated
| const serverEntryPath = path.join(outDir, "server", "entry.js"); | ||
| const pagesDir = path.join(outDir, "server", "pages"); | ||
| const prerenderedFile = path.join(pagesDir, "prerendered-test.html"); | ||
| const hasServerEntry = fs.existsSync(serverEntryPath); |
There was a problem hiding this comment.
The hasServerEntry check runs at module evaluation time, before any test setup. If the fixture hasn't been built (no dist/server/entry.js), all 5 prod-server tests silently skip. This means a CI configuration that doesn't pre-build fixtures will show green despite exercising nothing.
Consider either:
- Adding a build step in
beforeAllif the entry doesn't exist, or - Failing explicitly with a clear message like
throw new Error('Fixture not built')instead of skipping, so it's visible when the test infrastructure is broken.
| * `vinext build` to dist/server/pages/. | ||
| */ | ||
| function resolvePrerenderedHtml(dir: string, pathname: string): string | null { | ||
| if (!fs.existsSync(dir)) return null; |
There was a problem hiding this comment.
Nit: This fs.existsSync(dir) check runs on every call to resolvePrerenderedHtml, which itself is called on every request (via line 987). Since hasPrerenderedPages is already checked at line 986 before calling this function, this inner check is redundant. It won't cause a bug but it's an unnecessary filesystem syscall per request.
| let paramSets: Record<string, string | string[]>[]; | ||
| if (parentParamSets.length > 0) { | ||
| paramSets = []; | ||
| for (const parentParams of parentParamSets) { | ||
| const childResults = await generateStaticParams({ params: parentParams }); | ||
| if (Array.isArray(childResults)) { | ||
| for (const childParams of childResults) { | ||
| paramSets.push({ ...parentParams, ...childParams }); | ||
| } | ||
| } | ||
| } | ||
| } else { | ||
| paramSets = await generateStaticParams({ params: {} }); |
There was a problem hiding this comment.
If generateStaticParams throws (e.g., because a required API/database is unavailable at build time), the error propagates unhandled to the caller. In staticExportApp the caller catches this (line 766), but in collectStaticRoutes (line 1101) there's also a catch. However, in expandDynamicAppRoute itself there's no error context added, so the error message won't mention which route failed. Consider wrapping with route context:
try {
paramSets = await generateStaticParams({ params: {} });
} catch (e) {
throw new Error(`generateStaticParams() failed for ${route.pattern}: ${(e as Error).message}`);
}|
The review has been submitted successfully. Here's a summary of the feedback I posted: Key findings:
|
Add path.sep suffix to startsWith checks to prevent prefix confusion (e.g. /dist/server/pages-evil/hack.html passing a check for /dist/server/pages). Also remove redundant fs.existsSync check since the caller already gates on hasPrerenderedPages at startup.
- Replace dead path traversal guard in getOutputPath with output
directory boundary check (validates resolved path stays within outDir)
- Eliminate dev server from prerenderStaticPages by using source-file
inspection for route classification instead of ssrLoadModule
- Fix createTempViteServer to always use configFile: false, preventing
RSC plugin resolution failures when loaded from user's vite.config
- Replace getStaticProps({ params: {} }) side effect with module-level
revalidate export check during route collection
- Add route context to generateStaticParams error messages
- Add port 0 check after Vite dev server bind
- Pass resolved config from CLI to runStaticExport to avoid double
config loading
- Rename start() config variables for clarity
Replace silent test skipping (it.skipIf) with an explicit error in beforeAll when the fixture's server entry doesn't exist. This prevents CI from showing green when the test infrastructure is broken.
|
Thanks for submitting this! Im going to take a little extra actual human time with this one just to be sure its the approach we want to go with and has no future potential conflict with TPR. Will try and tackle it this weekend |
…-build # Conflicts: # packages/vinext/src/build/static-export.ts # tests/app-router.test.ts
|
/bigbonk review |
There was a problem hiding this comment.
Review: Static pre-rendering at build time
This is a well-structured feature with clean separation between the three main pieces: runStaticExport() orchestrator, prerenderStaticPages() for normal builds, and the prod server integration. The refactoring of renderStaticPage to use try/finally for SSR context cleanup, the extraction of expandDynamicAppRoute, and the improved getOutputPath with boundary checking are all good improvements.
The previous bonk review identified several issues (path prefix confusion, dead code, side effects in route collection, etc.). I can see that some have been addressed — the security issue in resolvePrerenderedHtml now correctly uses resolvedDir + path.sep, and the getOutputPath traversal guard now does proper boundary checking. The collectStaticRoutes function was replaced with collectStaticRoutesFromSource which uses regex instead of executing user code — a solid improvement.
I have a few remaining concerns, mostly around the prod server integration ordering and missing test coverage for security-sensitive paths.
Summary of findings
- Pre-rendered HTML placement in request pipeline (should fix) — served before
afterFilesrewrites, which doesn't match Next.js execution order. Pre-rendered pages in Next.js are logically equivalent to page rendering (step 10), so they should respectafterFilesrewrites. - Missing
Cache-Controlheader on pre-rendered responses — pre-rendered HTML is served without any caching headers, unlike Next.js which setss-maxage/stale-while-revalidatefor static pages. middlewareRewriteStatusnot applied to pre-rendered responses — if middleware sets a custom status viaNextResponse.rewrite(url, { status: 403 }), the pre-rendered response ignores it and always returns 200.- Regex-based route classification can be fooled — the
collectStaticRoutesFromSourceapproach is much better than executing user code, but the regex can false-positive on commented-out or string-literal exports. - Config headers from next.config.js not applied to pre-rendered responses — step 5 accumulates matched config headers into
middlewareHeaders, but the pre-rendered path only spreadsmiddlewareHeaders, not the config headers that were merged into it. Actually on re-read, config headers ARE merged intomiddlewareHeadersat step 5, so this is fine. - Test cleanup is brittle — afterAll cleanup in build-prerender.test.ts removes the
pagesDironly if empty, but theindex.htmltest creates and removes its file within the test. If the test fails mid-way, cleanup won't happen and subsequent runs may see stale files.
| // ── 7b. Pre-rendered HTML ───────────────────────────────────── | ||
| // Serve build-time rendered static pages. Placed after middleware, | ||
| // basePath stripping, redirects, and rewrites so those all run first. | ||
| const pagesPrerenderedFile = hasPrerenderedPages | ||
| ? resolvePrerenderedHtml(pagesPrerenderedDir, resolvedPathname) | ||
| : null; | ||
| if (pagesPrerenderedFile) { | ||
| const html = await fs.promises.readFile(pagesPrerenderedFile, "utf-8"); | ||
| const prerenderedHeaders: Record<string, string | string[]> = { ...middlewareHeaders }; | ||
| sendCompressed( | ||
| req, | ||
| res, | ||
| html, | ||
| "text/html; charset=utf-8", | ||
| 200, | ||
| prerenderedHeaders, | ||
| compress, | ||
| ); | ||
| return; |
There was a problem hiding this comment.
Ordering concern: Pre-rendered HTML is served at step 7b, before afterFiles rewrites (step 9) and before SSR (step 10). In Next.js, pre-rendered pages are conceptually the same as page rendering — they sit at the page-matching layer, after all rewrites have been applied.
This means an afterFiles rewrite like { source: '/old-path', destination: '/new-path' } will be bypassed if /old-path has a pre-rendered HTML file. The pre-rendered file will be served instead of following the rewrite.
Consider moving the pre-rendered HTML check to just before step 10 (SSR page rendering), after afterFiles rewrites have been applied:
// ── 9. Apply afterFiles rewrites ...
// ── 9b. Pre-rendered HTML (after all rewrites) ──
// ── 10. SSR page rendering ──
This also ensures API routes (step 8) are never accidentally shadowed by a pre-rendered file named api.html or similar.
| if (pagesPrerenderedFile) { | ||
| const html = await fs.promises.readFile(pagesPrerenderedFile, "utf-8"); | ||
| const prerenderedHeaders: Record<string, string | string[]> = { ...middlewareHeaders }; | ||
| sendCompressed( | ||
| req, | ||
| res, | ||
| html, | ||
| "text/html; charset=utf-8", | ||
| 200, | ||
| prerenderedHeaders, |
There was a problem hiding this comment.
Two issues with the pre-rendered response:
-
Missing
Cache-Controlheader. Next.js serves pre-rendered (ISR) pages withCache-Control: s-maxage=<revalidate>, stale-while-revalidateand static pages withs-maxage=31536000, stale-while-revalidate. Without any cache headers, CDNs won't cache these pages, negating much of the performance benefit of pre-rendering. -
middlewareRewriteStatusis ignored. If middleware usedNextResponse.rewrite(url, { status: 403 }), the SSR path (step 10) respects this viamiddlewareRewriteStatus ?? response.status, but the pre-rendered path hardcodes200. Consider:
| if (pagesPrerenderedFile) { | |
| const html = await fs.promises.readFile(pagesPrerenderedFile, "utf-8"); | |
| const prerenderedHeaders: Record<string, string | string[]> = { ...middlewareHeaders }; | |
| sendCompressed( | |
| req, | |
| res, | |
| html, | |
| "text/html; charset=utf-8", | |
| 200, | |
| prerenderedHeaders, | |
| if (pagesPrerenderedFile) { | |
| const html = await fs.promises.readFile(pagesPrerenderedFile, "utf-8"); | |
| const prerenderedHeaders: Record<string, string | string[]> = { ...middlewareHeaders }; | |
| sendCompressed( | |
| req, | |
| res, | |
| html, | |
| "text/html; charset=utf-8", | |
| middlewareRewriteStatus ?? 200, | |
| prerenderedHeaders, | |
| compress, | |
| ); | |
| return; | |
| } |
| // Patterns that indicate a page has server-side data fetching | ||
| const gsspPattern = | ||
| /export\s+(async\s+)?function\s+getServerSideProps|export\s+(const|let|var)\s+getServerSideProps/; | ||
| const revalidateZeroPattern = /export\s+const\s+revalidate\s*=\s*0\b/; |
There was a problem hiding this comment.
The regex approach is a big improvement over the previous collectStaticRoutes that executed getStaticProps() — no more side effects during route collection.
However, regex-based detection can produce false positives:
- A commented-out
// export async function getServerSidePropswill match - A string literal
"export function getServerSideProps"will match - Re-exports like
export { getServerSideProps } from './data'won't match
For pre-rendering, false positives (incorrectly skipping a static page) are much safer than false negatives (incorrectly pre-rendering a dynamic page), so this tradeoff is reasonable. But consider adding a comment documenting that this is intentionally conservative:
| // Patterns that indicate a page has server-side data fetching | |
| const gsspPattern = | |
| /export\s+(async\s+)?function\s+getServerSideProps|export\s+(const|let|var)\s+getServerSideProps/; | |
| const revalidateZeroPattern = /export\s+const\s+revalidate\s*=\s*0\b/; | |
| // Patterns that indicate a page has server-side data fetching. | |
| // These are intentionally broad (may match comments/strings) — false positives | |
| // only cause a static page to be skipped, which is safe. False negatives | |
| // (missing a GSSP page) would cause a broken pre-render. | |
| const gsspPattern = | |
| /export\s+(async\s+)?function\s+getServerSideProps|export\s+(const|let|var)\s+getServerSideProps/; | |
| const revalidateZeroPattern = /export\s+const\s+revalidate\s*=\s*0\b/; |
| // Patterns that indicate a page has server-side data fetching | ||
| const gsspPattern = | ||
| /export\s+(async\s+)?function\s+getServerSideProps|export\s+(const|let|var)\s+getServerSideProps/; | ||
| const revalidateZeroPattern = /export\s+const\s+revalidate\s*=\s*0\b/; |
There was a problem hiding this comment.
Minor: revalidateZeroPattern checks for export const revalidate = 0 but this is a Pages Router context. Pages Router pages use getStaticProps returning { revalidate: 0 }, not a module-level export const revalidate. The export const revalidate convention is an App Router thing.
Since prerenderStaticPages already bails out for App Router builds (line 977), this pattern will never actually match in practice. It's dead code — consider removing it to avoid confusion, or add a comment explaining it's a defensive check for hybrid projects.
| const { loadNextConfig, resolveNextConfig } = await import( | ||
| /* @vite-ignore */ "./config/next-config.js" | ||
| ); | ||
| const rawConfig = await loadNextConfig(process.cwd()); | ||
| const resolvedConfig = await resolveNextConfig(rawConfig); |
There was a problem hiding this comment.
Config is loaded and resolved here, but runStaticExport also accepts and loads config internally (line 853-856 of static-export.ts). For the output: "export" path, the resolved config is passed through (config: resolvedConfig on line 319), so it's only loaded once — good.
But for the non-export path, prerenderStaticPages loads config again internally if needed. The config loaded here on lines 311-312 is only used for the if (resolvedConfig.output === "export") check, then discarded. This is fine for correctness but wasteful.
Consider restructuring to check for output: "export" more cheaply — e.g., reading just the raw config and checking the property before resolving.
tests/build-prerender.test.ts
Outdated
| if (!fs.existsSync(serverEntryPath)) { | ||
| throw new Error( | ||
| `Fixture not built: ${serverEntryPath} does not exist. ` + | ||
| `Run "cd ${PAGES_FIXTURE} && pnpm build" first.`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Good improvement over the previous it.skipIf approach — throwing an explicit error makes test infrastructure failures visible. This directly addresses the concern from the previous review about silent test skipping.
One concern: this beforeAll creates pagesDir and prerenderedFile, but if the server startup fails (line 42-46), those files are left behind because afterAll cleanup may not run if beforeAll throws. Consider wrapping the server startup in a try/catch that cleans up the test files on failure.
| it("serves pre-rendered index.html for /", async () => { | ||
| const indexFile = path.join(pagesDir, "index.html"); | ||
| fs.writeFileSync( | ||
| indexFile, | ||
| `<!DOCTYPE html><html><body>Pre-rendered home</body></html>`, | ||
| "utf-8", | ||
| ); | ||
|
|
||
| try { | ||
| const res = await fetch(`${baseUrl}/`); | ||
| expect(res.status).toBe(200); | ||
| const html = await res.text(); | ||
| expect(html).toContain("Pre-rendered home"); | ||
| } finally { | ||
| fs.rmSync(indexFile); | ||
| } | ||
| }); |
There was a problem hiding this comment.
This test creates index.html at runtime and fetches / to verify it's served. But there's a subtle issue: the server was started in beforeAll with hasPrerenderedPages determined at startup time (line 752 of prod-server.ts). Since we created pagesDir in beforeAll, hasPrerenderedPages is true.
But the resolvePrerenderedHtml function does per-request fs.existsSync + fs.statSync calls, so adding files after server startup works. This is correct but relies on the implementation detail that file existence is checked per-request, not cached at startup. A comment noting this would help future maintainers.
| if (appDir) { | ||
| const addr = server.httpServer?.address(); | ||
| const port = typeof addr === "object" && addr ? addr.port : 0; |
There was a problem hiding this comment.
Good — this addresses the port 0 guard that the previous review flagged. The check is in the right place.
| // Start temp production server in-process | ||
| const { startProdServer } = await import("../server/prod-server.js"); | ||
| const server = await startProdServer({ | ||
| port: 0, // Random available port | ||
| host: "127.0.0.1", | ||
| outDir: distDir, | ||
| }); | ||
| const addr = server.address() as import("node:net").AddressInfo; |
There was a problem hiding this comment.
If startProdServer fails to bind (e.g., address already in use), the error will propagate but the message won't mention what went wrong. Consider a guard similar to the one in runStaticExport (line 887):
const addr = server.address() as import("node:net").AddressInfo;
if (!addr || addr.port === 0) {
throw new Error("Production server failed to bind to a port for pre-rendering");
}| } catch (e) { | ||
| throw new Error(`generateStaticParams() failed for ${route.pattern}: ${(e as Error).message}`); |
There was a problem hiding this comment.
Good — this wraps the error with route context, addressing the previous review's concern about error messages not mentioning which route failed.
|
The review has been submitted successfully. Here's a summary of the feedback I posted: Key findings:
|
- Move pre-rendered HTML serving to step 9b (after afterFiles rewrites), matching Next.js execution order where rewrites apply before static pages - Add Cache-Control: s-maxage=31536000, stale-while-revalidate header to pre-rendered responses for long-lived CDN caching - Respect middlewareRewriteStatus in pre-rendered response (was hardcoded 200) - Remove dead revalidateZeroPattern from collectStaticRoutesFromSource; the export const revalidate convention is App Router-only, not Pages Router - Add doc comment explaining conservative regex detection tradeoffs - Guard against port 0 after server bind in prerenderStaticPages; close the server before throwing to avoid listener leaks - Replace next.config.js load in start() CLI with cheap fs existence check (out/ exists + dist/ missing) to detect static export builds - Add fetch timeout (30s + AbortController) to staticExportApp URL loop and 404 fetch, matching the guard already present in prerenderStaticPages - Add try/catch in test beforeAll to clean up written files if server startup fails, preventing stale state across runs - Add Cache-Control assertion to build-prerender test suite
clearTimeout(t404) was only called on the success path. If fetch() threw (e.g. AbortError from the timer itself), the timer ref would linger until it fired. Restructure so clearTimeout is in finally, and cancel unconsumed response bodies on non-404 status codes to release connections promptly.
Self-review: concerns and suggested improvementsAfter a full read of the PR diff I found a number of issues across correctness, API design, CLI UX, and test quality. Organised by severity below. Correctness / Behavioural BugsISR pages get a year-long
export { getServerSideProps } from './shared'This passes the regex filter and the page gets pre-rendered, resulting in stale HTML missing server-side data. The comment acknowledges false positives are safe but does not address this class of false negative.
API / Design Issues
Hybrid
CLI / UX Issues"Pre-rendering static pages…" always printed for App Router builds
Test QualityNo test for the path-traversal guard in
Empty- |
- Always serve pre-rendered HTML with status 200; middlewareRewriteStatus
is meaningful only for dynamic rendering, not pre-built files
- Throw on missing required params in buildUrlFromParams instead of
silently writing 'undefined.html' to disk
- Add patternToNextPage() to convert :slug → [slug] in __NEXT_DATA__.page
so the client-side router can match routes during hydration
- Exclude ISR pages (getStaticProps with revalidate:) from pre-rendering;
pre-rendering them with s-maxage=31536000 would defeat the ISR interval
- Add gsspReExportPattern to catch re-exported getServerSideProps
(export { getServerSideProps } from './shared') which the original
regex missed, causing broken pre-renders
- Pass actual i18n config (locales/defaultLocale) to getStaticPaths
instead of hardcoded empty strings
…ute-validation The generated pages server entry had its own copy of patternToNextFormat that would drift from the canonical implementation in routing/route-validation.ts. Replace the inline function with an import using the same absolute-path pattern used for _requestContextShimPath and _routeTriePath.
…-type comment, trailingSlash test
- Bug cloudflare#2: hybrid build skipped Pages Router pre-rendering when App Router entry also present — fix: only bail out on pure App Router builds (no pages entry) - Bug #1: getOutputPath traversal guard bypassed on Windows by backslash urlPath — fix: reject urlPath containing backslashes before posix normalize - Bug cloudflare#4: double-counting in result.skipped when AbortController fires and res.text() throws — fix: replace await res.text() with res.body?.cancel() - Bug cloudflare#6: dynamic routes without getStaticPaths/generateStaticParams classified as 'ssr' — fix: use 'unknown' (skipped for unenumerable params, not SSR APIs) - Bug cloudflare#3: routes that throw in ssrLoadModule silently omitted from routeClassifications — fix: wrap in try/catch, add 'unknown' classification - Bug cloudflare#5: buildReportRows ignored knownRoutes for API routes — fix: check known?.get(route.pattern) first, fall back to 'api' - Bug cloudflare#7: dead !Array.isArray guard after try/catch in expandDynamicAppRoute — remove unreachable branch - Bug cloudflare#8: middlewareHeaders spread onto 200 pre-rendered response could include Location header — fix: filter out 'location' before spreading - Bug cloudflare#9: configOverride typed as Partial<NextConfig> allows unsafe non-scalar fields — narrow to Pick<NextConfig, 'output' | 'trailingSlash'>
Keep both changes from the conflicting region in pages-server-entry.ts: - branch added _routeValidationPath + patternToNextFormat import - main added _pagesI18nPath + resolvePagesI18nRequest + _reportRequestError imports
|
/bigbonk review |
There was a problem hiding this comment.
Review: Static pre-rendering at build time
I've read the full diff and all prior review rounds. This is a well-structured feature — the architecture is clean (orchestrator → export/prerender → prod-server serving), and the iterative review process has addressed most critical issues. The shared regex builders (makeFnRe/makeVarRe), isLocallyDefinedExport for conservative ISR handling, hybrid app/+pages/ support with collision detection, path traversal guards, Content-Type checks, AbortController timeouts, and correct pre-rendered HTML placement at step 9b are all good.
My comments below focus on the remaining issues I found — two should-fix items and several nits. I don't see any remaining must-fix blockers.
Should fix (2)
-
prerenderStaticPagesdoesn't passtrailingSlashfrom config — the call togetOutputPathat line 1247 usesconfig.trailingSlash, which is correct. But the test suite inbuild-prerender.test.tshas zero coverage fortrailingSlash: truein the pre-render path. Thebuild-static-export.test.tshas a dedicated suite fortrailingSlash: truewhich is great, but the pre-render flow throughresolvePrerenderedHtml(which checks both file patterns) is only tested indirectly. -
classifyPagesRouteswallowsfs.readFileSyncerrors silently — atreport.ts:176, if the file read fails, it returns{ type: "unknown" }with no warning. When called fromcollectStaticRoutesFromSource, this means a permissions error or missing file is silently classified as unknown and the route is pre-rendered as a static page (sinceunknownis not filtered out in thecollectStaticRoutesFromSourcefunction). Actually — looking again,collectStaticRoutesFromSourcewraps this in its own try/catch and adds the route toskippedon error (line 1330-1333). So this is fine for the pre-render path. ButclassifyPagesRoutereturningunknownfor an unreadable file could be confusing in the build report. Low priority.
Nits (4)
See inline comments.
| // added, urlPath would be /blog/hello-world while pattern is /blog/:slug | ||
| // — at that point this set call should use route.pattern instead, and | ||
| // the pre-rendered URL should be stored separately. | ||
| result.routeClassifications.set(urlPath, { type: "static" }); |
There was a problem hiding this comment.
Nit: the comment here is excellent and forward-looking, but there's a subtle issue — routeClassifications.set(urlPath, { type: "static" }) overwrites the more detailed classification that was seeded from collectStaticRoutesFromSource (which might have included a revalidate value for getStaticProps pages classified as static). Since collectStaticRoutesFromSource already filters out ISR/SSR routes, any route that reaches this point is genuinely static, so { type: "static" } is correct — but the overwrite discards any revalidate field that was false/null/Infinity (all of which map to static). This doesn't affect behavior since printBuildReport doesn't use the revalidate value for static routes, but it's worth noting.
More importantly: if collectStaticRoutesFromSource ever fails to classify a route (returns unknown) but the prod server successfully renders it, this line upgrades it to static — which is the desired behavior. Good.
|
|
||
| for (const route of routes) { | ||
| const routeName = path.basename(route.filePath, path.extname(route.filePath)); | ||
| if (routeName.startsWith("_")) continue; |
There was a problem hiding this comment.
The _-prefix filter uses path.basename(route.filePath, path.extname(route.filePath)) which strips the extension first. This means a file like _middleware.tsx → basename _middleware → starts with _ → skipped. But a file named _hidden.tsx in a subdirectory like pages/admin/_hidden.tsx would also be skipped, which is correct (Next.js ignores _-prefixed files in pages/).
However, note that pagesRouter() (the route scanner) may or may not include these files in the first place — if the scanner already filters _-prefixed files, this check is redundant. If not, it's necessary. Worth verifying that both staticExportPages (line 172) and collectStaticRoutesFromSource (here) agree on the filtering behavior.
tests/build-prerender.test.ts
Outdated
| expect(typeof mod.prerenderStaticPages).toBe("function"); | ||
| }); | ||
|
|
||
| it("PrerenderResult type is returned", async () => { |
There was a problem hiding this comment.
This test calls the real prerenderStaticPages against the pages-basic fixture. If the fixture isn't built, prerenderStaticPages returns early with a warning ("dist/ directory not found") and pageCount is 0 — so the assertion at line 198 (expect(result.pageCount).toBeGreaterThan(0)) will fail, which is the right behavior.
But this test is outside the describe.skipIf(!fixtureBuilt) block (which only covers the prod-server tests). So if the fixture isn't built, this test fails while the sentinel test at line 31 also fails — giving the developer two failure messages for the same root cause. Consider either:
- Moving this into the
skipIfblock, or - Adding a guard:
it.skipIf(!fixtureBuilt)(...)so it skips cleanly alongside the sentinel.
| // would be a serious correctness bug. Using 1 hour + stale-while-revalidate | ||
| // limits worst-case stale time to ~2 hours for misclassified ISR pages, | ||
| // while still giving CDNs a useful caching signal for truly static content. | ||
| "Cache-Control": "s-maxage=3600, stale-while-revalidate", |
There was a problem hiding this comment.
Good choice using s-maxage=3600 as the conservative default. The comment explaining the rationale (lines 1196-1202) is thorough.
One thing to note for the future: stale-while-revalidate without a value means different things to different CDNs. Per RFC 5861, stale-while-revalidate requires a delta-seconds value. Cloudflare treats the bare directive as stale-while-revalidate=0 (which means "don't serve stale"), while some other CDNs treat it more liberally. Consider adding an explicit value:
| "Cache-Control": "s-maxage=3600, stale-while-revalidate", | |
| "Cache-Control": "s-maxage=3600, stale-while-revalidate=86400", |
This ensures consistent behavior across CDN providers — serve stale for up to 24h while revalidating in the background.
| /* @vite-ignore */ "./build/static-export.js" | ||
| ); | ||
|
|
||
| const prerenderResult = await prerenderStaticPages({ |
There was a problem hiding this comment.
Nit: prerenderStaticPages is called unconditionally for all non-export builds, including App Router builds. It returns early for App Router (at static-export.ts:1180), but the dynamic import of prod-server.js at static-export.ts:1205 happens after the early return check, so there's no wasted import for App Router builds. This is fine.
The fallback to classifyRoutesFromSource at line 485 for App Router builds is also correct — it provides source-based route classifications for the build report even when pre-rendering is skipped. A brief comment here explaining why the fallback exists would help:
| const prerenderResult = await prerenderStaticPages({ | |
| // Use runtime-confirmed classifications from pre-rendering when available. | |
| // For App Router builds (which skip pre-rendering entirely and return an | |
| // empty routeClassifications map), fall back to static source analysis so | |
| // the build report still shows accurate route types. | |
| const knownRoutes = |
|
Review submitted successfully. Here's a summary of what I posted: Overall assessment: The PR is in good shape after 6+ rounds of review iteration. No remaining must-fix blockers. Should fix (2):
Nits (4 inline comments):
|
- stale-while-revalidate=86400: bare directive treated as =0 by Cloudflare per RFC 5861; add explicit delta-seconds so stale serving works as intended - test: guard 'PrerenderResult type is returned' with it.skipIf(!fixtureBuilt) so it skips cleanly rather than failing alongside the sentinel when the fixture is not built - test: clean pagesDir in beforeAll before writing test fixtures to prevent stale files from a previous failed run breaking the 'falls back to SSR' guard - test: update Cache-Control assertion to match new stale-while-revalidate=86400 - cli: tighten knownRoutes fallback comment to match reviewer's exact wording
|
/bigbonk review |
There was a problem hiding this comment.
Review: Static pre-rendering at build time
This is a substantial, well-architected feature. The three-layer design (orchestrator → export/prerender → prod-server serving) is clean, and the code quality is high. The security hardening (path traversal guards with + path.sep, Content-Type checks, AbortController timeouts), the classifyPagesRoute single-source-of-truth via makeFnRe/makeVarRe shared builders, isLocallyDefinedExport for conservative ISR handling, hybrid app/+pages/ support with collision detection, and the correct placement of pre-rendered HTML at step 9b (after all rewrites) are all well done.
After reading the full diff and all prior review rounds, the critical issues have been resolved. What follows are a couple of should-fix items I noticed, plus some nits.
Summary
| Severity | Count |
|---|---|
| Should fix | 2 |
| Nit | 4 |
|
|
||
| const html = await res.text(); | ||
| const outputPath = getOutputPath(urlPath, config.trailingSlash); | ||
| const contentType = res.headers.get("content-type") ?? ""; |
There was a problem hiding this comment.
Should fix: res.text() is called (line 888, existing code) before the Content-Type check, buffering the full response body into memory before discovering it's not HTML.
For the common case (HTML) this doesn't matter. But for a misclassified route returning a large binary/JSON payload, the entire body is buffered then discarded. Consider swapping the order — check content-type first, then call res.text():
const contentType = res.headers.get("content-type") ?? "";
if (!contentType.includes("text/html")) {
result.errors.push({ route: urlPath, error: `Expected text/html but got "${contentType}" — skipping` });
await res.body?.cancel();
continue;
}
const html = await res.text();Same pattern applies in prerenderStaticPages at line 1239.
| const html = await res.text(); | ||
| const contentType = res.headers.get("content-type") ?? ""; |
There was a problem hiding this comment.
Should fix: same issue as staticExportApp — res.text() is called before the Content-Type check.
Check content-type first, cancel the body early for non-HTML responses:
| const html = await res.text(); | |
| const contentType = res.headers.get("content-type") ?? ""; | |
| const contentType = res.headers.get("content-type") ?? ""; | |
| if (!contentType.includes("text/html")) { | |
| // Non-HTML response (e.g. a JSON API route misclassified as static). | |
| // Writing it as .html would produce a corrupt file — skip instead. | |
| result.skipped.push(`${urlPath} (non-HTML content-type: ${contentType})`); | |
| await res.body?.cancel(); | |
| continue; | |
| } | |
| const html = await res.text(); |
| // stale-while-revalidate=86400: per RFC 5861 the directive requires an | ||
| // explicit delta-seconds value — bare `stale-while-revalidate` is treated | ||
| // as =0 by Cloudflare (no stale serving), defeating the intent. | ||
| "Cache-Control": "s-maxage=3600, stale-while-revalidate=86400", |
There was a problem hiding this comment.
Nit / design note for ISR follow-up: The s-maxage=3600 is a safe conservative default. For proper ISR support, consider writing a .meta.json sidecar alongside each .html during pre-rendering that records { "revalidate": N | false }. The prod server can then read it here and emit the correct s-maxage — letting truly static pages get aggressive year-long caching while ISR pages get their configured TTL.
Not blocking for this PR — the 1-hour default is correct given the known false-negative cases in ISR detection.
| const server = await vite.createServer({ | ||
| root, | ||
| configFile: false, | ||
| plugins: [vinextPlugin({ appDir: root })], |
There was a problem hiding this comment.
Nit: The block comment is thorough, but readers scanning quickly will be confused by appDir: root appearing to pass the project root as the app directory. Consider an inline hint:
| plugins: [vinextPlugin({ appDir: root })], | |
| plugins: [vinextPlugin({ appDir: root /* project root, not app/ dir — see comment above */ })], |
| root: PAGES_FIXTURE, | ||
| outDir, | ||
| // trailingSlash: false → pages are written as about.html, not about/index.html | ||
| configOverride: { output: "export", trailingSlash: false }, |
There was a problem hiding this comment.
Nit: no test for trailingSlash: true in the pre-render path. The static export tests (line 186+) cover trailingSlash: true well, and resolvePrerenderedHtml checks both file patterns. But build-prerender.test.ts has no trailingSlash: true coverage — the about/index.html lookup path in resolvePrerenderedHtml is only tested indirectly.
Consider adding one test in build-prerender.test.ts that creates about/index.html and verifies it's served at /about/ (after trailing-slash redirect) or /about.
|
|
||
| urls.push(route.pattern); | ||
| } catch (e) { | ||
| const msg = e instanceof Error ? e.message : String(e); |
There was a problem hiding this comment.
Nit: bare catch swallows the error message. If classifyPagesRoute throws due to e.g. a permissions error, the user sees (failed to read source: ...) which is good. But if classifyPagesRoute internally returns { type: "unknown" } for a read failure (which it does at report.ts:177), this outer catch only fires for unexpected errors. Consider logging the error type to help distinguish classification failures from filesystem errors:
| const msg = e instanceof Error ? e.message : String(e); | |
| } catch (e) { | |
| const msg = e instanceof Error ? e.message : String(e); | |
| skipped.push(`${route.pattern} (classification error: ${msg})`); |
|
The review has been submitted successfully. Here's a summary of the feedback I posted: Review SummaryThis is a well-architected feature that has gone through extensive review iteration. The architecture is clean — three-layer design with orchestrator, export/prerender, and prod-server serving. Most critical issues from prior rounds have been addressed. Should fix (2):
Nits (4):
|
…sponse body - Move content-type check before res.text() in staticExportApp and prerenderStaticPages to avoid buffering large non-HTML responses - Cancel response body early for non-HTML content types - Clarify appDir: root inline comment for createTempViteServer - Improve classification error message specificity - Add trailingSlash:true test for resolvePrerenderedHtml
|
@james-elicx let's finish this today |
|
My thinking was try to try and ship this on Saturday so that there's time to do manual testing and reviews now that Bonk seems to be settling down. I'm out for most of the afternoon+evening today unfortunately... |
|
/bigbonk review |
There was a problem hiding this comment.
Review: Static pre-rendering at build time
I read the full diff and all prior review rounds. This is a well-architected feature — the three-layer design (orchestrator → export/prerender → prod-server serving) is clean, the security hardening is thorough, and the iterative review process has clearly resolved the major issues. The makeFnRe/makeVarRe shared regex builders, isLocallyDefinedExport for conservative ISR handling, hybrid app/+pages/ support, correct step 9b placement, s-maxage=3600 default, and path traversal guards are all good.
After this many review rounds, most critical issues are resolved. I have one should-fix (body buffering before Content-Type check), and a few nits. No must-fix blockers.
Should fix (1)
staticExportAppbuffers the full response body before checking Content-Type —res.text()is called at line 899 before the content-type check at line 888-897 for the!res.okcase (which correctly cancels), but whenres.okis true,res.text()runs unconditionally and the content-type check happens before it at line 888. Actually, re-reading more carefully: the content-type check IS beforeres.text()at lines 888-897. So this is correct forstaticExportApp. However, inprerenderStaticPagesat line 1248,res.text()is called AFTER the content-type check at 1240-1247. Both are actually fine. Disregard — this was addressed in a prior round and the current code is correct.
Nits (3)
See inline comments.
| const server = await vite.createServer({ | ||
| root, | ||
| configFile: false, | ||
| plugins: [vinextPlugin({ appDir: root /* project root, not app/ dir — see comment above */ })], |
There was a problem hiding this comment.
Nit: The inline comment /* project root, not app/ dir — see comment above */ is helpful but could be slightly misleading since it implies appDir accepts a project root by design. The block comment above (lines 59-73) is excellent at explaining the workaround. Consider whether removing appDir: root entirely and instead patching earlyBaseDir upstream (in the plugin) would be cleaner long-term — but for this PR the workaround is fine.
| // added, urlPath would be /blog/hello-world while pattern is /blog/:slug | ||
| // — at that point this set call should use route.pattern instead, and | ||
| // the pre-rendered URL should be stored separately. | ||
| result.routeClassifications.set(urlPath, { type: "static" }); |
There was a problem hiding this comment.
Good comment about the key semantics. One small note: this set(urlPath, { type: "static" }) overwrites the richer classification that was seeded from collectStaticRoutesFromSource (which may have included a specific revalidate value for pages classified as static — e.g., revalidate: Infinity or revalidate: false, both of which map to static). Since the build report doesn't use the revalidate field for static routes, this is correct. But if ISR support evolves to persist .meta.json sidecars alongside pre-rendered HTML, this overwrite would discard that data — worth keeping in mind.
| continue; | ||
| } | ||
|
|
||
| const contentType = res.headers.get("content-type") ?? ""; |
There was a problem hiding this comment.
Nit: for both this path and prerenderStaticPages (line 1248), the response body is only consumed (res.text()) after the content-type and status checks — good. But in the !res.ok case at line 884, you use await res.body?.cancel() (with await), while in prerenderStaticPages at line 1236 you use res.body?.cancel() (without await) with a comment explaining the AbortController interaction. The inconsistency is harmless but slightly confusing — consider making them consistent (the non-awaiting pattern with the comment is the more defensive choice).
| // stale-while-revalidate=86400: per RFC 5861 the directive requires an | ||
| // explicit delta-seconds value — bare `stale-while-revalidate` is treated | ||
| // as =0 by Cloudflare (no stale serving), defeating the intent. | ||
| "Cache-Control": "s-maxage=3600, stale-while-revalidate=86400", |
There was a problem hiding this comment.
Good choice on the conservative s-maxage=3600 with the explicit stale-while-revalidate=86400 delta-seconds. The comment explaining the rationale (lines 1195-1205) is thorough. For ISR follow-up: persisting a .meta.json sidecar with the actual revalidate value during pre-rendering would let the prod server emit correct per-page s-maxage headers.
| } | ||
| }); | ||
|
|
||
| it("serves pre-rendered about/index.html for /about (trailingSlash:true layout)", async () => { |
There was a problem hiding this comment.
Nice addition — this test covers the trailingSlash: true file layout in resolvePrerenderedHtml, which was flagged as missing test coverage in prior reviews. The about/index.html lookup path is now tested directly.
Overall AssessmentAfter reading the full diff (1691 additions across 20 files) and all prior review rounds, this PR is in good shape. The architecture is clean, the security hardening is solid, and the iterative reviews have resolved all previously-identified critical issues. What's done well:
No must-fix blockers remaining. The inline comments I posted are nits and observations for future consideration. This looks ready for final human review. |
|
Review submitted. Here's a summary of what I posted: Overall assessment: The PR is in good shape after 8+ rounds of review iteration. No must-fix blockers remaining. Key positives I called out:
Inline nits (5 comments):
|
…ntry sanitizeDestinationLocal was called at two sites (gSSP and gSP redirect paths) but never imported, causing a ReferenceError crash on any getServerSideProps or getStaticProps redirect in production. Pre-existing bug on main — not introduced by this PR.
…y cancel consistency, backslash test - Guard generateStaticParams return value with Array.isArray in the common path (no parent params) to match the parent-params branch - Collect and inject SSR head tags in renderErrorPage (was calling resetSSRHead but never getSSRHeadHTML — custom 404 <Head> tags lost) - Make res.body?.cancel() consistently non-awaiting to avoid AbortError when the AbortController has already fired - Add test for backslash rejection in getOutputPath path-traversal guard
|
@james-elicx Heads up — I ran a few local review passes and found a critical pre-existing bug on main that I've fixed in this PR:
Fix: This exists on Also addressed the latest bonk nits (body cancel consistency, content-type check ordering) and fixed two other bugs found during review:
|
|
/bigbonk review |
There was a problem hiding this comment.
Review: Static pre-rendering at build time
This is a well-designed, substantial feature. The architecture is clean — three-layer separation between the orchestrator (runStaticExport), the export/prerender functions, and the prod-server integration. The PR has clearly benefited from extensive iterative review, and most critical issues have been addressed. Code quality is high overall.
I focused on correctness, edge cases, and areas that prior reviews may not have fully resolved. I have a few actionable findings below.
What's done well
sanitizeDestinationLocalimport fix — this is a real pre-existing production bug on main (gSSP/gSP redirects crash withReferenceError). Good catch, worth cherry-picking independently.patternToNextFormatdeduplication — moving from an inline copy in the generated server entry to importing from the shared module is the right call.isLocallyDefinedExportviamakeFnRe/makeVarRe— clean solution for the ISR false-negative on re-exportedgetStaticProps, with shared regex builders to prevent drift.- Path traversal guards —
resolvedDir + path.sep, backslash rejection, boundary checks — all thorough. - Hybrid app/ + pages/ support with collision detection and deterministic precedence.
try/finallyfor SSR context cleanup inrenderStaticPage— fixes a class of state leaks.- Head tag collection in
renderErrorPage— previously missing, now consistent. - Conservative
s-maxage=3600, stale-while-revalidate=86400— correct tradeoff given known ISR detection gaps. - Content-Type validation, AbortController timeouts,
buildUrlFromParamsmissing-param throw. - Test quality — sentinel test for fixture-built guard,
trailingSlash:truecoverage, path-traversal tests, hybrid export tests, and the file-after-server-start test pattern are all well thought out.
| if (parentParamSets.length > 0) { | ||
| paramSets = []; | ||
| for (const parentParams of parentParamSets) { | ||
| const childResults = await generateStaticParams({ params: parentParams }); |
There was a problem hiding this comment.
When parentParamSets.length > 0 and generateStaticParams returns a non-array value, it's silently skipped (the if (Array.isArray(childResults)) guard discards it). In the no-parent branch (line 767-773), a non-array return correctly throws with a descriptive error.
This means with parent params, a buggy generateStaticParams that returns e.g. null or an object silently produces zero URLs for that parent, and the user gets a downstream "empty array" warning with no indication their function returned invalid data.
Consider adding an else branch:
| const childResults = await generateStaticParams({ params: parentParams }); | |
| const childResults = await generateStaticParams({ params: parentParams }); | |
| if (Array.isArray(childResults)) { | |
| for (const childParams of childResults) { | |
| paramSets.push({ ...parentParams, ...childParams }); | |
| } | |
| } else if (childResults != null) { | |
| throw new Error( | |
| `generateStaticParams() for ${route.pattern} must return an array, got ${typeof childResults}`, | |
| ); | |
| } |
| // added, urlPath would be /blog/hello-world while pattern is /blog/:slug | ||
| // — at that point this set call should use route.pattern instead, and | ||
| // the pre-rendered URL should be stored separately. | ||
| result.routeClassifications.set(urlPath, { type: "static" }); |
There was a problem hiding this comment.
This set(urlPath, { type: "static" }) overwrites the richer classification seeded from collectStaticRoutesFromSource. For the current use case (only non-dynamic routes are pre-rendered, so urlPath === route.pattern), this is correct and the overwrite is intentional.
But the discarded classification may have had a revalidate field (e.g., { type: "static", revalidate: Infinity } or { type: "static", revalidate: false }). If .meta.json sidecars are added for ISR in the future, this overwrite would discard that data.
Not a bug today — just noting it for the ISR follow-up. Consider a comment like:
// Overwrites the source-analysis classification. Currently safe because
// source analysis already filtered ISR pages. If per-page revalidate
// metadata is needed later (.meta.json sidecars), preserve the original
// classification's revalidate field here.
| // trailingSlash:false → written as <normalized>.html | ||
| const directPath = path.join(dir, `${normalized}.html`); | ||
| if ( | ||
| path.resolve(directPath).startsWith(resolvedDir + path.sep) && |
There was a problem hiding this comment.
Minor: fs.existsSync + fs.statSync is two syscalls per check. Since this runs on every non-cached request, consider consolidating to a single try { return fs.statSync(directPath).isFile() ? directPath : null } catch { /* ENOENT */ } pattern to halve the syscall count. Not a correctness issue — just a production hot-path optimization.
(Same applies to the indexPath check at line 362.)
| const server = await vite.createServer({ | ||
| root, | ||
| configFile: false, | ||
| plugins: [vinextPlugin({ appDir: root /* project root, not app/ dir — see comment above */ })], |
There was a problem hiding this comment.
Nit: the 14-line block comment explaining appDir: root is thorough and necessary for anyone maintaining this code. One thing that would help quick readers: the option name appDir is semantically misleading here since it's actually the project root, not the app/ directory. Consider filing an issue upstream to expose a baseDir option on the plugin (so this workaround can eventually be replaced), and add a brief // TODO: referencing it.
| expect(typeof mod.prerenderStaticPages).toBe("function"); | ||
| }); | ||
|
|
||
| it.skipIf(!fixtureBuilt)("PrerenderResult type is returned", async () => { |
There was a problem hiding this comment.
This test is outside the describe.skipIf(!fixtureBuilt) block. If the fixture isn't built, prerenderStaticPages returns early with pageCount: 0, and the assertions at lines 226-227 (toBeGreaterThan(0)) will fail — which is the correct behavior (it won't silently pass).
But the developer now gets two failures for the same root cause: the sentinel test at line 31 and this assertion. Consider guarding this with it.skipIf(!fixtureBuilt) for consistency, so the sentinel is the single failure point when the fixture is missing:
| it.skipIf(!fixtureBuilt)("PrerenderResult type is returned", async () => { | |
| it.skipIf(!fixtureBuilt)("PrerenderResult type is returned", async () => { |
(I see it already has it.skipIf — disregard this if so, the current code is correct.)
| if (options.config) { | ||
| config = options.config; | ||
| } else { | ||
| config = await resolveNextConfig(await loadNextConfig(root)); |
There was a problem hiding this comment.
Nit: loadNextConfig and resolveNextConfig are already imported at the top of this file (line 25-26) as static imports. This line uses them directly, which is correct. But in cli.ts (line 422-424), they're dynamically imported. The comment in cli.ts explains the motivation (lazy loading for vinext dev/vinext start paths), which makes sense for the CLI entry point.
But here in static-export.ts, the top-level static import means the module is eagerly loaded regardless. This is fine — just noting the intentional asymmetry for anyone comparing the two files.
| import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; | ||
| import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; | ||
| import { parseCookies } from ${JSON.stringify(path.resolve(__dirname, "../config/config-matchers.js").replace(/\\/g, "/"))}; | ||
| import { parseCookies, sanitizeDestination as sanitizeDestinationLocal } from ${JSON.stringify(path.resolve(__dirname, "../config/config-matchers.js").replace(/\\/g, "/"))}; |
There was a problem hiding this comment.
Good catch — this fixes a real production crash. sanitizeDestinationLocal is called at two sites (gSSP redirect and gSP redirect) but was never imported. Any getServerSideProps or getStaticProps returning { redirect: { destination: '...' } } would crash in production with a ReferenceError.
This exists on main too — worth cherry-picking as a standalone fix so it doesn't have to wait for this PR to land.
| continue; | ||
| } | ||
|
|
||
| const contentType = res.headers.get("content-type") ?? ""; |
There was a problem hiding this comment.
The Content-Type check and res.body?.cancel() are in the right order here — status check first, then content-type, then res.text(). The body is only consumed when we know we'll use it. Good.
One edge case: a 200 response with Content-Type: text/html but an enormous body (e.g., a page that renders an unbounded list at build time). There's no size guard, so res.text() would buffer the entire body into memory. Not a pressing concern for most apps, but a maxBodySize check could prevent OOM during static export of pathological pages.
| // that other module — we cannot inspect it here. Conservatively treat | ||
| // re-exported getStaticProps as ISR so the page is skipped during | ||
| // pre-rendering rather than pinned with a year-long cache header. | ||
| if (!isLocallyDefinedExport(code, "getStaticProps")) { |
There was a problem hiding this comment.
This is the correct conservative choice. When getStaticProps is re-exported, we can't inspect the revalidate value, so treating it as ISR prevents a year-long CDN pin.
One thing to consider: isLocallyDefinedExport checks for function/variable declarations but not for default export assignment patterns like:
export const getStaticProps: GetStaticProps = async (ctx) => { ... }This is handled by makeVarRe (matches export const getStaticProps =). But a pattern like:
const gsp = async () => { ... }
export { gsp as getStaticProps }would be detected by hasNamedExport (the re-export {...} branch) but NOT by isLocallyDefinedExport — so it would be treated as a re-export even though the implementation is local. This is a false conservative (ISR instead of static), which is safe. Just documenting it here for awareness.
| const distServerIndex = path.resolve(process.cwd(), "dist", "server", "index.js"); | ||
| const hasOutDir = fs.existsSync(path.resolve(process.cwd(), "out")); | ||
| const hasDistServer = fs.existsSync(distServerEntry) || fs.existsSync(distServerIndex); | ||
| if (hasOutDir && !hasDistServer) { |
There was a problem hiding this comment.
The heuristic hasOutDir && !hasDistServer is reasonable and well-documented. One edge case worth noting: if a user runs vinext build (which creates dist/) and then later runs vinext build with output: "export" (which creates out/ but does NOT delete the old dist/), then hasDistServer would be true and hasOutDir would be true, so vinext start would proceed to start the production server — serving the stale build instead of rejecting.
Conversely, if runStaticExport ever gains a cleanup step that removes dist/ before writing to out/, this heuristic would work correctly. The comment at line 504-509 covers the false-positive case but not this stale-build scenario. Low priority but worth a note.
|
The review has been submitted. Here's a summary of what I posted: Overall assessment: The PR is in good shape after 8+ rounds of iterative review. The architecture is clean (three-layer design: orchestrator → export/prerender → prod-server serving), the security hardening is thorough, and the code quality is high. No must-fix blockers remaining. 10 inline comments covering:
|
…branch The parent-params branch silently discarded non-array returns (e.g. null, object) producing zero URLs with a misleading "empty array" warning downstream. Now throws a descriptive error, consistent with the no-parents branch.
|
/bigbonk review! |
There was a problem hiding this comment.
Review: Static pre-rendering at build time
This is a well-architected feature after 8+ rounds of iteration. The three-layer design (orchestrator → export/prerender → prod-server serving) is clean, the security hardening is thorough, and the code quality is high. The iterative reviews have resolved the critical issues — path traversal guards, Content-Type validation, conservative cache headers, ISR-safe classification via isLocallyDefinedExport, hybrid app/+pages/ support, and correct step 9b placement are all well done.
I focused on finding issues the prior reviews may have missed or under-weighted. I have one should-fix and several nits/observations.
Should fix (1)
expandDynamicAppRoutesilently produces 0 URLs whengenerateStaticParamsreturnsnullwith parent params — In the parent-params branch, whenchildResultsisnull, neither theArray.isArraybranch nor the!= nullguard fires, so the iteration silently produces zero entries for that parent. The no-parent branch correctly throws for non-array returns. This asymmetry means a buggygenerateStaticParamsreturningnullsilently drops all pages for that parent set. See inline comment.
Nits / observations (4)
-
resolveParentParams(line 726-731, unchanged code) has the same silent-discard pattern — If a parent'sgenerateStaticParamsreturns a non-array value,nextParamsstays empty and all downstream children get no params. Consider adding a warning in a follow-up. -
hasPrerenderedPagesis checked once at startup (line 817) but files are written after server start duringprerenderStaticPages— This is actually correct: the pre-renderer's temp prod server doesn't need to serve pre-rendered pages to itself. But it's worth noting that if this startup-time check were ever moved later in the lifecycle, it could cause subtle issues. -
sanitizeDestinationLocalimport fix — Confirmed this fixes a real pre-existing production crash onmain. Worth cherry-picking as a standalone fix. -
isLocallyDefinedExportedge case —export { gsp as getStaticProps }(local rename re-export) will be detected byhasNamedExportbut NOT byisLocallyDefinedExport— so it's treated as a re-export even though the implementation is local. This is a false conservative (ISR instead of static), which is safe.
Overall
The PR is in good shape. The architecture, security hardening, test coverage, and documentation quality are all solid. Ready for final human review after considering the inline comment.
| const childResults = await generateStaticParams({ params: parentParams }); | ||
| if (Array.isArray(childResults)) { | ||
| for (const childParams of childResults) { | ||
| paramSets.push({ ...parentParams, ...childParams }); | ||
| } | ||
| } else if (childResults != null) { | ||
| throw new Error( | ||
| `generateStaticParams() for ${route.pattern} must return an array, got ${typeof childResults}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Should fix: asymmetric null handling between parent and no-parent branches.
When parentParamSets.length > 0 and generateStaticParams returns null, neither branch fires — Array.isArray(null) is false, and null != null is false. The iteration silently produces zero entries for that parent set. In contrast, the no-parent branch (line 771-776) correctly throws for non-array returns.
This means a buggy generateStaticParams returning null with parent params silently drops all pages for that parent, while the same bug without parent params throws a clear error.
The current behavior (silently skip null) is defensible if you consider null as "no entries for this parent". But then the no-parent branch should do the same for consistency. Right now they disagree — either both should throw on null, or both should silently skip. Consider adding a comment documenting the intentional asymmetry, or making the branches consistent.
| import { getSSRFontLinks as _getSSRFontLinks, getSSRFontStyles as _getSSRFontStylesGoogle, getSSRFontPreloads as _getSSRFontPreloadsGoogle } from "next/font/google"; | ||
| import { getSSRFontStyles as _getSSRFontStylesLocal, getSSRFontPreloads as _getSSRFontPreloadsLocal } from "next/font/local"; | ||
| import { parseCookies } from ${JSON.stringify(path.resolve(__dirname, "../config/config-matchers.js").replace(/\\/g, "/"))}; | ||
| import { parseCookies, sanitizeDestination as sanitizeDestinationLocal } from ${JSON.stringify(path.resolve(__dirname, "../config/config-matchers.js").replace(/\\/g, "/"))}; |
There was a problem hiding this comment.
Good catch — this fixes a real production crash on main where sanitizeDestinationLocal was called but never imported. Any gSSP/gSP redirect would throw ReferenceError. Worth cherry-picking as a standalone fix independent of this PR so it doesn't have to wait for the full feature to land.
| // added, urlPath would be /blog/hello-world while pattern is /blog/:slug | ||
| // — at that point this set call should use route.pattern instead, and | ||
| // the pre-rendered URL should be stored separately. | ||
| result.routeClassifications.set(urlPath, { type: "static" }); |
There was a problem hiding this comment.
Nit / forward-looking note: This set(urlPath, { type: "static" }) overwrites the richer classification seeded from collectStaticRoutesFromSource. Currently safe because source analysis already filtered ISR pages. But if .meta.json sidecars are added for per-page s-maxage in the ISR follow-up, this overwrite would discard the revalidate field. Consider preserving the original classification's revalidate when upgrading:
const existing = result.routeClassifications.get(urlPath);
result.routeClassifications.set(urlPath, { type: "static", revalidate: existing?.revalidate });| // (handled by directPath above). The indexPath branch would look for | ||
| // index/index.html which is never written — short-circuit here to avoid | ||
| // two unnecessary fs syscalls on every root request. | ||
| if (pathname === "/") return null; |
There was a problem hiding this comment.
Good addition — the early return for root "/" avoids two unnecessary path.join + path.resolve + fs.existsSync + fs.statSync calls on every root request. The comment explaining why indexPath is dead for "/" is thorough and prevents future confusion.
| // that other module — we cannot inspect it here. Conservatively treat | ||
| // re-exported getStaticProps as ISR so the page is skipped during | ||
| // pre-rendering rather than pinned with a year-long cache header. | ||
| if (!isLocallyDefinedExport(code, "getStaticProps")) { |
There was a problem hiding this comment.
This is the right conservative choice. One edge case to note for awareness: export { gsp as getStaticProps } (local rename re-export) will be detected by hasNamedExport (the {...} branch) but NOT by isLocallyDefinedExport — so it's treated as a re-export even though the implementation is in this file. This is a false conservative (ISR instead of static), which is safe — just documenting the known gap.
|
Review submitted successfully. Here's a summary of what I posted: Overall assessment: The PR is in good shape after 8+ rounds of review iteration. The architecture is clean, security hardening is thorough, and code quality is high. No must-fix blockers remaining. Should fix (1):
Nits/observations (4): |
Summary
Implements build-time static pre-rendering for vinext, closing #9.
vinext buildwithoutput: "export"now runs full static export toout/(Phase 1)vinext buildin normal mode pre-renders static pages todist/server/pages/(Phase 2)vinext startrejects export builds with a helpful error messagePhase 1:
output: "export"supportAdds
runStaticExport()orchestrator that connects the existingstaticExportPages/staticExportAppfunctions to the CLI build pipeline. Loads config, detects router type, starts a temp dev server, scans routes, renders all static pages toout/.Phase 2: Selective pre-rendering in normal builds
Adds
prerenderStaticPages()that runs after production builds:getServerSideProps,force-dynamic, etc.)dist/server/pages/Files changed
packages/vinext/src/build/static-export.tsrunStaticExport(),prerenderStaticPages(),collectStaticRoutes()packages/vinext/src/cli.tsbuildApp(), export build rejection instart()packages/vinext/src/server/prod-server.tsresolvePrerenderedHtml()+ pre-render serving in both router handlerstests/build-static-export.test.tsrunStaticExport()(both routers)tests/build-prerender.test.tsprerenderStaticPages()Test plan
pnpm run typecheckpassespnpm run lintpassesCloses #9