Skip to content
Closed
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
5e56610
feat: wire output: 'export' to vinext build
NathanDrake2406 Mar 5, 2026
2149bed
feat: add prerenderStaticPages() for build-time pre-rendering
NathanDrake2406 Mar 5, 2026
bbcf6e4
feat: serve pre-rendered HTML from prod server
NathanDrake2406 Mar 5, 2026
f44f446
feat: wire prerenderStaticPages() into vinext build
NathanDrake2406 Mar 5, 2026
1b8a0f9
fix: address 6 bugs found in code review
NathanDrake2406 Mar 5, 2026
df3830f
fix: downgrade missing generateStaticParams/getStaticPaths from error…
NathanDrake2406 Mar 6, 2026
ce830ad
fix: address code review findings in static export
NathanDrake2406 Mar 6, 2026
5be5d1c
refactor: improve observability and type safety in static export
NathanDrake2406 Mar 6, 2026
69a5835
fix: address 5 issues from code review audit
NathanDrake2406 Mar 6, 2026
0ad2979
merge: resolve conflict with upstream/main
NathanDrake2406 Mar 7, 2026
0c2c9f6
fix: prevent path prefix confusion in resolvePrerenderedHtml
NathanDrake2406 Mar 7, 2026
dcc6f65
fix: address code review feedback on static export
NathanDrake2406 Mar 7, 2026
b0dd390
test: fail explicitly when fixture not built
NathanDrake2406 Mar 7, 2026
64a6496
Merge remote-tracking branch 'origin/main' into feat/static-prerender…
james-elicx Mar 10, 2026
0a8f485
fmt: run oxfmt after merge with origin/main
james-elicx Mar 10, 2026
aab3b9b
merge: resolve conflict with origin/main
james-elicx Mar 10, 2026
2eee3d7
fix: address review feedback on static pre-rendering
james-elicx Mar 10, 2026
9271012
fix: move 404 timeout to finally block in staticExportApp
james-elicx Mar 10, 2026
b53c335
fix: correctness bugs in static export and pre-rendering
james-elicx Mar 10, 2026
cd6b149
fix: API/design and CLI polish
james-elicx Mar 10, 2026
4021f5d
fix: test robustness — skip without fixture, path-traversal tests, ex…
james-elicx Mar 10, 2026
8d1471c
fix: trailing-slash, content-type guard, parent-load warning, and cod…
james-elicx Mar 10, 2026
8562487
fix: address round 3 review findings
james-elicx Mar 10, 2026
5c8447d
Merge remote-tracking branch 'origin/main' into feat/static-prerender…
james-elicx Mar 11, 2026
46342f9
address bonk review comments (round 4): isrPattern, catch error messa…
james-elicx Mar 11, 2026
3e46d71
feat: hybrid app/ + pages/ static export support
james-elicx Mar 11, 2026
a9392bc
fix: address round 5 bonk review comments
james-elicx Mar 11, 2026
e442ab7
Merge remote-tracking branch 'origin/main' into feat/static-prerender…
james-elicx Mar 11, 2026
34da71d
feat: wire pre-render/export routeClassifications into printBuildReport
james-elicx Mar 11, 2026
184f4d5
fix: address bonk review comments (ISR false-negative, cache TTL, tes…
james-elicx Mar 12, 2026
be65641
fix: pass appDir to createTempViteServer so App Router RSC plugin loa…
james-elicx Mar 12, 2026
8b31729
fmt
james-elicx Mar 12, 2026
d90fb0d
fix: address bonk review comments — dedup regex, static imports, earl…
james-elicx Mar 12, 2026
a9c12b8
fix: address second round of bonk review comments
james-elicx Mar 12, 2026
dfc4a8b
fix: address third round of bonk review comments — deduplicate regex …
james-elicx Mar 12, 2026
be4686c
fix: replace inline patternToNextFormat duplicate with import from ro…
james-elicx Mar 12, 2026
e437567
address review: stale jsdoc, bare catch, pageCount collision, content…
james-elicx Mar 12, 2026
2d54763
fix: 9 self-review bugs in static prerender build
james-elicx Mar 12, 2026
48e382d
merge: main into feat/static-prerender-build
james-elicx Mar 12, 2026
7698281
revuild lockfile
james-elicx Mar 12, 2026
5c2e1b2
address review comments (2026-03-12 19:09)
james-elicx Mar 12, 2026
95cfad9
regen snaps
james-elicx Mar 12, 2026
60ed01d
fix: address review feedback — check content-type before buffering re…
NathanDrake2406 Mar 13, 2026
6cbca62
fix: import missing sanitizeDestinationLocal in Pages Router server e…
NathanDrake2406 Mar 13, 2026
b619afc
fix: review findings — Array.isArray guard, error page head tags, bod…
NathanDrake2406 Mar 13, 2026
da5af57
fix: throw on non-array generateStaticParams return in parent-params …
NathanDrake2406 Mar 13, 2026
61d0f08
fix: throw on null generateStaticParams return in parent-params branch
NathanDrake2406 Mar 14, 2026
1a1c906
Merge remote-tracking branch 'upstream/main' into feat/static-prerend…
NathanDrake2406 Mar 14, 2026
efa09e6
chore: regenerate lockfile after merge with upstream/main
NathanDrake2406 Mar 14, 2026
3e26eb5
ci: retrigger checks
NathanDrake2406 Mar 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 76 additions & 9 deletions packages/vinext/src/build/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ export interface RouteRow {

// ─── Regex-based export detection ────────────────────────────────────────────

// Shared regex builders — a single source of truth for the fn/var patterns
// used by both hasNamedExport and isLocallyDefinedExport. Extracting them here
// prevents the two functions from silently drifting if the pattern is ever
// updated (e.g. to handle `export declare function` or TypeScript's
// `export const foo: Foo = ...`).

function makeFnRe(name: string): RegExp {
return new RegExp(`(?:^|\\n)\\s*export\\s+(?:async\\s+)?function\\s+${name}\\b`);
}

function makeVarRe(name: string): RegExp {
return new RegExp(`(?:^|\\n)\\s*export\\s+(?:const|let|var)\\s+${name}\\s*[=:]`);
}

/**
* Returns true if the source code contains a named export with the given name.
* Handles all three common export forms:
Expand All @@ -46,12 +60,10 @@ export interface RouteRow {
*/
export function hasNamedExport(code: string, name: string): boolean {
// Function / generator / async function declaration
const fnRe = new RegExp(`(?:^|\\n)\\s*export\\s+(?:async\\s+)?function\\s+${name}\\b`);
if (fnRe.test(code)) return true;
if (makeFnRe(name).test(code)) return true;

// Variable declaration (const / let / var)
const varRe = new RegExp(`(?:^|\\n)\\s*export\\s+(?:const|let|var)\\s+${name}\\s*[=:]`);
if (varRe.test(code)) return true;
if (makeVarRe(name).test(code)) return true;

// Re-export specifier: export { foo } or export { foo as bar }
const reRe = new RegExp(`export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`);
Expand All @@ -60,6 +72,30 @@ export function hasNamedExport(code: string, name: string): boolean {
return false;
}

/**
* Returns true if the source code contains a named export that is locally
* defined in this file (function declaration or variable assignment), as
* opposed to a re-export from another module (`export { foo } from '...'`).
*
* This distinction matters for `getStaticProps`: a locally-defined
* `getStaticProps` has its `revalidate` value readable in this file.
* A re-exported one (`export { getStaticProps } from './data'`) has its
* implementation in another module — we cannot inspect its revalidate value.
*
* Uses the same fn/var regex builders as `hasNamedExport` (via `makeFnRe` /
* `makeVarRe`) so the two functions always use identical patterns.
* Deliberately excludes the re-export `{ foo }` branch.
*/
export function isLocallyDefinedExport(code: string, name: string): boolean {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix: isLocallyDefinedExport duplicates the first two branches of hasNamedExport

The function/variable regexes in isLocallyDefinedExport (lines 75-76, 79-80) are identical copies of those in hasNamedExport (lines 49-50, 53-54). If either regex is updated (e.g., to handle export declare function, export generator function*, or TypeScript export const getStaticProps: GetStaticProps = ...), the other will silently fall out of sync.

Consider implementing isLocallyDefinedExport in terms of hasNamedExport:

Suggested change
export function isLocallyDefinedExport(code: string, name: string): boolean {
export function isLocallyDefinedExport(code: string, name: string): boolean {
// A locally-defined export is one that hasNamedExport detects but is NOT
// a re-export (`export { name }` or `export { name } from '...'`).
if (!hasNamedExport(code, name)) return false;
// Check for function or variable declaration (same patterns as hasNamedExport)
const fnRe = new RegExp(`(?:^|\\n)\\s*export\\s+(?:async\\s+)?function\\s+${name}\\b`);
if (fnRe.test(code)) return true;
const varRe = new RegExp(`(?:^|\\n)\\s*export\\s+(?:const|let|var)\\s+${name}\\s*[=:]`);
if (varRe.test(code)) return true;
return false;
}

Or extract the shared regexes into constants/helpers. The current duplication is small but the functions are called in a correctness-critical path (ISR detection affects cache TTLs).

// Function / generator / async function declaration
if (makeFnRe(name).test(code)) return true;

// Variable declaration (const / let / var).
// Note: intentionally omits the re-export `export { name }` form — that
// indicates the implementation lives in another module, not locally here.
return makeVarRe(name).test(code);
}
Comment on lines +89 to +97
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix: duplicated regex patterns from hasNamedExport

The fnRe and varRe patterns here (lines 78, 84) are exact copies of lines 49-50 and 53-54 in hasNamedExport. This function is called in a correctness-critical path — ISR detection determines whether a page gets a 1-hour or year-long cache header. If someone updates one regex (e.g. to handle export declare function or export default function), the other silently falls out of sync.

Consider implementing isLocallyDefinedExport in terms of hasNamedExport to avoid the duplication:

Suggested change
export function isLocallyDefinedExport(code: string, name: string): boolean {
// Function / generator / async function declaration
const fnRe = new RegExp(`(?:^|\\n)\\s*export\\s+(?:async\\s+)?function\\s+${name}\\b`);
if (fnRe.test(code)) return true;
// Variable declaration (const / let / var).
// Note: intentionally omits the re-export `export { name }` form — that
// indicates the implementation lives in another module, not locally here.
const varRe = new RegExp(`(?:^|\\n)\\s*export\\s+(?:const|let|var)\\s+${name}\\s*[=:]`);
return varRe.test(code);
}
export function isLocallyDefinedExport(code: string, name: string): boolean {
// A locally-defined export is detected by the fn/var patterns but NOT
// the re-export `export { name }` form. Rather than duplicating the
// regexes from hasNamedExport, check that hasNamedExport matches AND
// that the match is NOT solely from a re-export.
if (!hasNamedExport(code, name)) return false;
// Function / generator / async function declaration
const fnRe = new RegExp(`(?:^|\\n)\\s*export\\s+(?:async\\s+)?function\\s+${name}\\b`);
if (fnRe.test(code)) return true;
// Variable declaration (const / let / var)
const varRe = new RegExp(`(?:^|\\n)\\s*export\\s+(?:const|let|var)\\s+${name}\\s*[=:]`);
return varRe.test(code);
}

Or better — extract the shared regex builders into named helpers so both functions reference the same source of truth.


/**
* Extracts the string value of `export const <name> = "value"`.
* Handles optional TypeScript type annotations:
Expand Down Expand Up @@ -146,6 +182,15 @@ export function classifyPagesRoute(filePath: string): {
}

if (hasNamedExport(code, "getStaticProps")) {
// If getStaticProps is a re-export from another module (e.g.
// `export { getStaticProps } from './data'`), the revalidate value lives in
// 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")) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

return { type: "isr" };
}

const revalidate = extractGetStaticPropsRevalidate(code);

if (revalidate === null || revalidate === false || revalidate === Infinity) {
Expand Down Expand Up @@ -221,25 +266,41 @@ export function classifyAppRoute(
/**
* Builds a sorted list of RouteRow objects from the discovered routes.
* Routes are sorted alphabetically by path, matching filesystem order.
*
* @param options.knownRoutes - Optional map of pattern → classification override.
* When provided, entries from this map take precedence over the regex-based
* analysis for matching routes. Used to pass in confirmed classifications from
* the static export or pre-render phase (which has runtime-verified knowledge
* of route types), replacing "unknown" with accurate data.
*/
export function buildReportRows(options: {
pageRoutes?: Route[];
apiRoutes?: Route[];
appRoutes?: AppRoute[];
knownRoutes?: Map<string, { type: RouteType; revalidate?: number }>;
}): RouteRow[] {
const rows: RouteRow[] = [];
const known = options.knownRoutes;

for (const route of options.pageRoutes ?? []) {
const { type, revalidate } = classifyPagesRoute(route.filePath);
const override = known?.get(route.pattern);
const { type, revalidate } = override ?? classifyPagesRoute(route.filePath);
rows.push({ pattern: route.pattern, type, revalidate });
}

for (const route of options.apiRoutes ?? []) {
rows.push({ pattern: route.pattern, type: "api" });
const override = known?.get(route.pattern);
rows.push({
pattern: route.pattern,
type: override?.type ?? "api",
revalidate: override?.revalidate,
});
}

for (const route of options.appRoutes ?? []) {
const { type, revalidate } = classifyAppRoute(route.pagePath, route.routePath, route.isDynamic);
const override = known?.get(route.pattern);
const { type, revalidate } =
override ?? classifyAppRoute(route.pagePath, route.routePath, route.isDynamic);
rows.push({ pattern: route.pattern, type, revalidate });
}

Expand Down Expand Up @@ -336,10 +397,16 @@ function findDir(root: string, ...candidates: string[]): string | null {
* Next.js-style build report to stdout.
*
* Called at the end of `vinext build` in cli.ts.
*
* @param options.knownRoutes - Optional map of pattern → confirmed classification.
* When provided, these override the regex-based analysis for matching routes.
* Callers should populate this from the static export or pre-render results
* to provide fully accurate route type information.
*/
export async function printBuildReport(options: {
root: string;
pageExtensions?: string[];
knownRoutes?: Map<string, { type: RouteType; revalidate?: number }>;
}): Promise<void> {
const { root } = options;

Expand All @@ -352,7 +419,7 @@ export async function printBuildReport(options: {
// Dynamic import to avoid loading routing code unless needed
const { appRouter } = await import("../routing/app-router.js");
const routes = await appRouter(appDir, options.pageExtensions);
const rows = buildReportRows({ appRoutes: routes });
const rows = buildReportRows({ appRoutes: routes, knownRoutes: options.knownRoutes });
if (rows.length > 0) {
console.log("\n" + formatBuildReport(rows, "app"));
}
Expand All @@ -364,7 +431,7 @@ export async function printBuildReport(options: {
pagesRouter(pagesDir, options.pageExtensions),
apiRouter(pagesDir, options.pageExtensions),
]);
const rows = buildReportRows({ pageRoutes, apiRoutes });
const rows = buildReportRows({ pageRoutes, apiRoutes, knownRoutes: options.knownRoutes });
if (rows.length > 0) {
console.log("\n" + formatBuildReport(rows, "pages"));
}
Expand Down
Loading
Loading