diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts
index 6fcb49b34..d0ef55a21 100644
--- a/packages/vinext/src/build/report.ts
+++ b/packages/vinext/src/build/report.ts
@@ -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:
@@ -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[^}]*\\}`);
@@ -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 {
+ // 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);
+}
+
/**
* Extracts the string value of `export const = "value"`.
* Handles optional TypeScript type annotations:
@@ -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")) {
+ return { type: "isr" };
+ }
+
const revalidate = extractGetStaticPropsRevalidate(code);
if (revalidate === null || revalidate === false || revalidate === Infinity) {
@@ -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;
}): 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 });
}
@@ -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;
}): Promise {
const { root } = options;
@@ -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"));
}
@@ -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"));
}
diff --git a/packages/vinext/src/build/static-export.ts b/packages/vinext/src/build/static-export.ts
index 79de1cec6..aa32a99d8 100644
--- a/packages/vinext/src/build/static-export.ts
+++ b/packages/vinext/src/build/static-export.ts
@@ -8,19 +8,28 @@
* Pages Router:
* - Static pages → render to HTML
* - getStaticProps pages → call at build time, render with props
- * - Dynamic routes → call getStaticPaths (must be fallback: false), render each
+ * - Dynamic routes → call getStaticPaths, render each (fallback: false required)
+ * - Dynamic routes without getStaticPaths → warning, skipped
* - getServerSideProps → build error
* - API routes → skipped with warning
*
* App Router:
* - Static pages → run Server Components at build time, render to HTML
* - Dynamic routes → call generateStaticParams(), render each
- * - Dynamic routes without generateStaticParams → build error
+ * - Dynamic routes without generateStaticParams → warning, skipped
*/
import type { ViteDevServer } from "vite";
import { patternToNextFormat, type Route } from "../routing/pages-router.js";
import type { AppRoute } from "../routing/app-router.js";
-import type { ResolvedNextConfig } from "../config/next-config.js";
+import {
+ loadNextConfig,
+ resolveNextConfig,
+ type ResolvedNextConfig,
+ type NextConfig,
+} from "../config/next-config.js";
+import { pagesRouter, apiRouter } from "../routing/pages-router.js";
+import { appRouter } from "../routing/app-router.js";
+import { classifyPagesRoute, classifyAppRoute, type RouteType } from "./report.js";
import { safeJsonStringify } from "../server/html.js";
import { escapeAttr } from "../shims/head.js";
import path from "node:path";
@@ -29,6 +38,51 @@ import React from "react";
import { renderToReadableStream } from "react-dom/server.edge";
import { createValidFileMatcher, type ValidFileMatcher } from "../routing/file-matcher.js";
+/**
+ * Create a temporary Vite dev server for a project root.
+ *
+ * Always uses configFile: false with vinext loaded directly from this
+ * package. Loading from the user's vite.config causes module resolution
+ * issues: the config-file vinext instance resolves @vitejs/plugin-rsc
+ * from a different path than the inline instance, causing instanceof
+ * checks to fail and the RSC middleware to silently not handle requests.
+ *
+ * Pass `listen: true` to bind an HTTP port (needed for fetching pages).
+ */
+async function createTempViteServer(
+ root: string,
+ opts: { listen?: boolean } = {},
+): Promise {
+ const vite = await import("vite");
+ const { default: vinextPlugin } = await import("../index.js");
+
+ // `appDir: root` is a workaround for earlyBaseDir initialisation order.
+ //
+ // vinextPlugin() captures `earlyBaseDir = appDir ?? process.cwd()` at
+ // *construction time*, before the Vite `config()` hook fires and before
+ // `root` is known to the plugin. That value is used to resolve
+ // @vitejs/plugin-rsc from the project's own node_modules. Without it, when
+ // this function is invoked from tests (where process.cwd() is the monorepo
+ // root), earlyAppDirExists evaluates to false, rscPluginPromise is set to
+ // null, and the RSC middleware is never loaded — causing all App Router
+ // requests to return 404.
+ //
+ // Passing `appDir: root` ensures earlyBaseDir equals the fixture/project root
+ // so @vitejs/plugin-rsc resolves from the correct node_modules. The value
+ // is semantically the project root here, not necessarily a directory named
+ // "app/" — it overrides the default process.cwd() fallback only.
+ const server = await vite.createServer({
+ root,
+ configFile: false,
+ plugins: [vinextPlugin({ appDir: root /* project root, not app/ dir — see comment above */ })],
+ optimizeDeps: { holdUntilCrawlEnd: true },
+ server: { port: 0, cors: false },
+ logLevel: "silent",
+ });
+ if (opts.listen) await server.listen();
+ return server;
+}
+
function findFileWithExtensions(basePath: string, matcher: ValidFileMatcher): boolean {
return matcher.dottedExtensions.some((ext) => fs.existsSync(basePath + ext));
}
@@ -67,6 +121,13 @@ export interface StaticExportResult {
warnings: string[];
/** Errors encountered (non-fatal, specific pages) */
errors: Array<{ route: string; error: string }>;
+ /**
+ * Confirmed route classifications keyed by URL pattern.
+ * Populated from runtime data (module execution, getStaticPaths, etc.) —
+ * more accurate than the regex-based analysis in report.ts.
+ * Consumed by printBuildReport() to show correct route types.
+ */
+ routeClassifications: Map;
}
/**
@@ -82,6 +143,7 @@ export async function staticExportPages(options: StaticExportOptions): Promise;
+ try {
+ pageModule = await server.ssrLoadModule(route.filePath);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ result.routeClassifications.set(route.pattern, { type: "unknown" });
+ result.warnings.push(`Failed to load ${route.pattern}: ${msg} — skipping`);
+ continue;
+ }
// Validate: getServerSideProps is not allowed with static export
if (typeof pageModule.getServerSideProps === "function") {
+ result.routeClassifications.set(route.pattern, { type: "ssr" });
result.errors.push({
route: route.pattern,
error: `Page uses getServerSideProps which is not supported with output: 'export'. Use getStaticProps instead.`,
@@ -118,22 +192,25 @@ export async function staticExportPages(options: StaticExportOptions): Promise = {};
+ // Collect page props
+ let pageProps: Record = {};
- if (typeof pageModule.getStaticProps === "function") {
- const result = await pageModule.getStaticProps({ params });
- if (result && "props" in result) {
- pageProps = result.props as Record;
- }
- if (result && "redirect" in result) {
- // Static export can't handle redirects — write a meta redirect
- const redirect = result.redirect as { destination: string };
- return ``;
- }
- if (result && "notFound" in result && result.notFound) {
- throw new Error(`Page ${urlPath} returned notFound: true`);
+ if (typeof pageModule.getStaticProps === "function") {
+ const result = await pageModule.getStaticProps({ params });
+ if (result && "props" in result) {
+ pageProps = result.props as Record;
+ }
+ if (result && "redirect" in result) {
+ // Static export can't handle redirects — write a meta redirect
+ const redirect = result.redirect as { destination: string };
+ return ``;
+ }
+ if (result && "notFound" in result && result.notFound) {
+ throw new Error(`Page ${urlPath} returned notFound: true`);
+ }
}
- }
- // Build element
- const createElement = React.createElement;
- let element: React.ReactElement;
-
- if (AppComponent) {
- element = createElement(AppComponent, {
- Component: PageComponent,
- pageProps,
- });
- } else {
- element = createElement(PageComponent, pageProps);
- }
+ // Build element
+ const createElement = React.createElement;
+ let element: React.ReactElement;
- // Reset head collector and flush dynamic preloads
- if (typeof headShim.resetSSRHead === "function") {
- headShim.resetSSRHead();
- }
- if (typeof dynamicShim.flushPreloads === "function") {
- await dynamicShim.flushPreloads();
- }
+ if (AppComponent) {
+ element = createElement(AppComponent, {
+ Component: PageComponent,
+ pageProps,
+ });
+ } else {
+ element = createElement(PageComponent, pageProps);
+ }
- // Render page body
- const bodyHtml = await renderToStringAsync(element);
+ // Reset head collector and flush dynamic preloads
+ if (typeof headShim.resetSSRHead === "function") {
+ headShim.resetSSRHead();
+ }
+ if (typeof dynamicShim.flushPreloads === "function") {
+ await dynamicShim.flushPreloads();
+ }
- // Collect head tags
- const ssrHeadHTML =
- typeof headShim.getSSRHeadHTML === "function" ? headShim.getSSRHeadHTML() : "";
+ // Render page body
+ const bodyHtml = await renderToStringAsync(element);
- // __NEXT_DATA__ for client hydration
- const nextDataScript = ``;
+ // Collect head tags
+ const ssrHeadHTML =
+ typeof headShim.getSSRHeadHTML === "function" ? headShim.getSSRHeadHTML() : "";
+
+ // __NEXT_DATA__ for client hydration.
+ // `page` must use Next.js bracket notation ([slug]) so the client-side
+ // router can match this route during hydration. Colon notation (:slug)
+ // is vinext's internal format and is not understood by next/router.
+ const nextDataScript = ``;
- // Build HTML shell
- let html: string;
+ // Build HTML shell
+ let html: string;
- if (DocumentComponent) {
- const docElement = createElement(DocumentComponent);
- // renderToReadableStream auto-prepends when root is
- let docHtml = await renderToStringAsync(docElement);
- docHtml = docHtml.replace("__NEXT_MAIN__", bodyHtml);
- if (ssrHeadHTML) {
- docHtml = docHtml.replace("", ` ${ssrHeadHTML}\n`);
- }
- docHtml = docHtml.replace("", nextDataScript);
- if (!docHtml.includes("__NEXT_DATA__")) {
- docHtml = docHtml.replace("
", ` ${nextDataScript}\n`);
- }
- html = docHtml;
- } else {
- html = `
+ if (DocumentComponent) {
+ const docElement = createElement(DocumentComponent);
+ // renderToReadableStream auto-prepends when root is
+ let docHtml = await renderToStringAsync(docElement);
+ docHtml = docHtml.replace("__NEXT_MAIN__", bodyHtml);
+ if (ssrHeadHTML) {
+ docHtml = docHtml.replace("", ` ${ssrHeadHTML}\n`);
+ }
+ docHtml = docHtml.replace("", nextDataScript);
+ if (!docHtml.includes("__NEXT_DATA__")) {
+ docHtml = docHtml.replace("", ` ${nextDataScript}\n`);
+ }
+ html = docHtml;
+ } else {
+ html = `
@@ -372,14 +466,15 @@ async function renderStaticPage(options: RenderStaticPageOptions): Promise
`;
- }
+ }
- // Clear SSR context
- if (typeof routerShim.setSSRContext === "function") {
- routerShim.setSSRContext(null);
+ return html;
+ } finally {
+ // Always clear SSR context, even if rendering throws
+ if (typeof routerShim.setSSRContext === "function") {
+ routerShim.setSSRContext(null);
+ }
}
-
- return html;
}
interface RenderErrorPageOptions {
@@ -430,11 +525,18 @@ async function renderErrorPage(options: RenderErrorPageOptions): Promise, meta) set via next/head during render.
+ const ssrHeadHTML =
+ typeof headShim.getSSRHeadHTML === "function" ? headShim.getSSRHeadHTML() : "";
+
let html: string;
if (DocumentComponent) {
const docElement = createElement(DocumentComponent);
let docHtml = await renderToStringAsync(docElement);
docHtml = docHtml.replace("__NEXT_MAIN__", bodyHtml);
+ if (ssrHeadHTML) {
+ docHtml = docHtml.replace("", ` ${ssrHeadHTML}\n`);
+ }
docHtml = docHtml.replace("", "");
html = docHtml;
} else {
@@ -443,6 +545,7 @@ async function renderErrorPage(options: RenderErrorPageOptions): Promise
+ ${ssrHeadHTML}
${bodyHtml}
@@ -479,6 +582,11 @@ function buildUrlFromParams(pattern: string, params: Record;
+ }) => Promise[]>,
+): Promise {
+ const parentParamSets = await resolveParentParams(route, allRoutes, server);
+
+ let paramSets: Record[];
+ try {
+ 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 {
+ throw new Error(
+ `generateStaticParams() for ${route.pattern} must return an array, got ${typeof childResults}`,
+ );
+ }
+ }
+ } else {
+ const raw = await generateStaticParams({ params: {} });
+ if (!Array.isArray(raw)) {
+ throw new Error(
+ `generateStaticParams() for ${route.pattern} must return an array, got ${typeof raw}`,
+ );
+ }
+ paramSets = raw;
+ }
+ } catch (e) {
+ throw new Error(`generateStaticParams() failed for ${route.pattern}: ${(e as Error).message}`);
+ }
+
+ return paramSets.map((params) => buildUrlFromParams(route.pattern, params));
+}
+
// -------------------------------------------------------------------
// App Router static export
// -------------------------------------------------------------------
@@ -605,8 +792,6 @@ export interface AppStaticExportOptions {
baseUrl: string;
/** Discovered app routes */
routes: AppRoute[];
- /** App directory path (for loading modules to call generateStaticParams) */
- appDir: string;
/** Vite dev server (for loading page modules) */
server: ViteDevServer;
/** Output directory */
@@ -630,6 +815,7 @@ export async function staticExportApp(
files: [],
warnings: [],
errors: [],
+ routeClassifications: new Map(),
};
fs.mkdirSync(outDir, { recursive: true });
@@ -640,6 +826,7 @@ export async function staticExportApp(
for (const route of routes) {
// Skip API route handlers — not supported in static export
if (route.routePath && !route.pagePath) {
+ result.routeClassifications.set(route.pattern, { type: "api" });
result.warnings.push(
`Route handler ${route.pattern} skipped — API routes are not supported with output: 'export'`,
);
@@ -654,72 +841,83 @@ export async function staticExportApp(
const pageModule = await server.ssrLoadModule(route.pagePath);
if (typeof pageModule.generateStaticParams !== "function") {
- result.errors.push({
- route: route.pattern,
- error: `Dynamic route requires generateStaticParams() with output: 'export'`,
- });
+ // Classify as "unknown", not "ssr": the route is skipped because its
+ // params can't be enumerated statically, not because it uses SSR APIs.
+ result.routeClassifications.set(route.pattern, { type: "unknown" });
+ result.warnings.push(
+ `Dynamic route ${route.pattern} has no generateStaticParams() — skipping (no pages generated)`,
+ );
continue;
}
- // Resolve parent dynamic segments for top-down params passing.
- // Find all other routes whose patterns are prefixes of this route's pattern
- // and that have dynamic params, then collect their generateStaticParams.
- const parentParamSets = await resolveParentParams(route, routes, server);
-
- let paramSets: Record[];
- if (parentParamSets.length > 0) {
- // Top-down: call child's generateStaticParams for each parent param set
- paramSets = [];
- for (const parentParams of parentParamSets) {
- const childResults = await pageModule.generateStaticParams({ params: parentParams });
- if (Array.isArray(childResults)) {
- for (const childParams of childResults) {
- paramSets.push({ ...parentParams, ...childParams });
- }
- }
- }
- } else {
- // Bottom-up: no parent params, call with empty params
- paramSets = await pageModule.generateStaticParams({ params: {} });
- }
+ const expandedUrls = await expandDynamicAppRoute(
+ route,
+ routes,
+ server,
+ pageModule.generateStaticParams,
+ );
- if (!Array.isArray(paramSets) || paramSets.length === 0) {
+ if (expandedUrls.length === 0) {
+ result.routeClassifications.set(route.pattern, { type: "unknown" });
result.warnings.push(
`generateStaticParams() for ${route.pattern} returned empty array — no pages generated`,
);
continue;
}
- for (const params of paramSets) {
- const urlPath = buildUrlFromParams(route.pattern, params);
- urlsToRender.push(urlPath);
- }
+ // Has generateStaticParams — statically pre-rendered at build time.
+ // Use classifyAppRoute to pick up any explicit revalidate config.
+ result.routeClassifications.set(
+ route.pattern,
+ classifyAppRoute(route.pagePath, route.routePath, route.isDynamic),
+ );
+ urlsToRender.push(...expandedUrls);
} catch (e) {
result.errors.push({
route: route.pattern,
- error: `Failed to call generateStaticParams(): ${(e as Error).message}`,
+ error: (e as Error).message,
});
}
} else {
- // Static route
+ // Static route — classify and queue for rendering
+ result.routeClassifications.set(
+ route.pattern,
+ classifyAppRoute(route.pagePath, route.routePath, route.isDynamic),
+ );
urlsToRender.push(route.pattern);
}
}
// Fetch each URL from the dev server and write HTML
for (const urlPath of urlsToRender) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 30_000);
try {
- const res = await fetch(`${baseUrl}${urlPath}`);
+ const res = await fetch(`${baseUrl}${urlPath}`, { signal: controller.signal });
if (!res.ok) {
result.errors.push({
route: urlPath,
error: `Server returned ${res.status}`,
});
+ // Cancel without awaiting — avoids throwing an AbortError if the
+ // AbortController already fired (same pattern as prerenderStaticPages).
+ res.body?.cancel();
continue;
}
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("text/html")) {
+ // Non-HTML response (e.g. a JSON response from a misclassified route).
+ // Writing it as .html would produce a corrupt file — record as error.
+ result.errors.push({
+ route: urlPath,
+ error: `Expected text/html but got "${contentType}" — skipping`,
+ });
+ res.body?.cancel();
+ continue;
+ }
const html = await res.text();
- const outputPath = getOutputPath(urlPath, config.trailingSlash);
+ const outputPath = getOutputPath(urlPath, config.trailingSlash, outDir);
const fullPath = path.join(outDir, outputPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, html, "utf-8");
@@ -731,12 +929,18 @@ export async function staticExportApp(
route: urlPath,
error: (e as Error).message,
});
+ } finally {
+ clearTimeout(timer);
}
}
// Render 404 page
+ const ctrl404 = new AbortController();
+ const t404 = setTimeout(() => ctrl404.abort(), 30_000);
try {
- const res = await fetch(`${baseUrl}/__nonexistent_page_for_404__`);
+ const res = await fetch(`${baseUrl}/__nonexistent_page_for_404__`, {
+ signal: ctrl404.signal,
+ });
if (res.status === 404) {
const html = await res.text();
if (html.length > 0) {
@@ -744,11 +948,482 @@ export async function staticExportApp(
fs.writeFileSync(fullPath, html, "utf-8");
result.files.push("404.html");
result.pageCount++;
+ } else {
+ res.body?.cancel();
}
+ } else {
+ res.body?.cancel();
}
} catch {
// No custom 404, skip
+ } finally {
+ clearTimeout(t404);
}
return result;
}
+
+// -------------------------------------------------------------------
+// High-level orchestrator
+// -------------------------------------------------------------------
+
+export interface RunStaticExportOptions {
+ root: string;
+ outDir?: string;
+ config?: ResolvedNextConfig;
+ /**
+ * Scalar config overrides applied on top of the resolved config.
+ * Only safe scalar fields are permitted — non-scalar fields like
+ * `pageExtensions` or `redirects` require re-running resolveNextConfig
+ * and cannot be shallow-merged correctly.
+ */
+ configOverride?: Pick;
+}
+
+/**
+ * High-level orchestrator for static export.
+ *
+ * Loads next.config from the project root, detects the router type,
+ * starts a temporary Vite dev server, scans routes, runs the appropriate
+ * static export (Pages or App Router), and returns the result.
+ */
+export async function runStaticExport(
+ options: RunStaticExportOptions,
+): Promise {
+ const { root, configOverride } = options;
+ const outDir = options.outDir ?? path.join(root, "out");
+
+ // 1. Load and resolve config (reuse caller's config if provided, but always
+ // apply configOverride on top so callers can force e.g. output: "export")
+ let config: ResolvedNextConfig;
+ if (options.config) {
+ if (configOverride && Object.keys(configOverride).length > 0) {
+ // Apply the override directly on the already-resolved config. The
+ // typical fields in configOverride (output, trailingSlash) are scalars
+ // that need no further resolution, so a shallow merge is sufficient.
+ config = { ...options.config, ...configOverride } as ResolvedNextConfig;
+ } else {
+ config = options.config;
+ }
+ } else {
+ const loadedConfig = await loadNextConfig(root);
+ const merged: NextConfig = { ...loadedConfig, ...configOverride };
+ config = await resolveNextConfig(merged);
+ }
+
+ // 2. Detect router type
+ const appDirCandidates = [path.join(root, "app"), path.join(root, "src", "app")];
+ const pagesDirCandidates = [path.join(root, "pages"), path.join(root, "src", "pages")];
+
+ const appDir = appDirCandidates.find((d) => fs.existsSync(d));
+ const pagesDir = pagesDirCandidates.find((d) => fs.existsSync(d));
+
+ if (!appDir && !pagesDir) {
+ return {
+ pageCount: 0,
+ files: [],
+ warnings: ["No app/ or pages/ directory found — nothing to export"],
+ errors: [],
+ routeClassifications: new Map(),
+ };
+ }
+
+ // 3. Start a temporary Vite dev server (with listener for HTTP fetching)
+ const server = await createTempViteServer(root, { listen: true });
+
+ try {
+ // 4. Clean output directory
+ fs.rmSync(outDir, { recursive: true, force: true });
+ fs.mkdirSync(outDir, { recursive: true });
+
+ // 5. Scan routes and run export
+ //
+ // Hybrid app/ + pages/ layout is supported: both routers run in sequence
+ // and their results are merged. App Router is exported first, then Pages
+ // Router. If both routers produce a file with the same relative path (e.g.
+ // both have an index route), the Pages Router file wins — it is written
+ // second and overwrites the App Router file on disk. A warning is emitted
+ // for each collision so the user can resolve the overlap.
+ if (appDir) {
+ const addr = server.httpServer?.address();
+ const port = typeof addr === "object" && addr ? addr.port : 0;
+ if (port === 0) {
+ throw new Error("Vite dev server failed to bind to a port");
+ }
+ const baseUrl = `http://localhost:${port}`;
+
+ const appRoutes = await appRouter(appDir);
+ const appResult = await staticExportApp({
+ baseUrl,
+ routes: appRoutes,
+ server,
+ outDir,
+ config,
+ });
+
+ if (!pagesDir) {
+ return appResult;
+ }
+
+ // Hybrid: also export the Pages Router into the same outDir.
+ const pageRoutes = await pagesRouter(pagesDir);
+ const apiRoutes = await apiRouter(pagesDir);
+ const pagesResult = await staticExportPages({
+ server,
+ routes: pageRoutes,
+ apiRoutes,
+ pagesDir,
+ outDir,
+ config,
+ });
+
+ // Detect file collisions (same relative path produced by both routers).
+ const appFilesSet = new Set(appResult.files);
+ const pagesFilesSet = new Set(pagesResult.files);
+ const collisionWarnings: string[] = [];
+ for (const f of pagesResult.files) {
+ if (appFilesSet.has(f)) {
+ collisionWarnings.push(
+ `[vinext] Hybrid export collision: both App Router and Pages Router produced "${f}" — Pages Router output takes precedence`,
+ );
+ }
+ }
+
+ return {
+ pageCount: appResult.pageCount + pagesResult.pageCount - collisionWarnings.length,
+ files: [
+ // Deduplicate: for colliding paths, pagesResult wins (it was written
+ // second, overwriting the app result file on disk). Emit only the
+ // de-duped union so the caller's file list matches what's on disk.
+ ...appResult.files.filter((f) => !pagesFilesSet.has(f)),
+ ...pagesResult.files,
+ ],
+ warnings: [...appResult.warnings, ...pagesResult.warnings, ...collisionWarnings],
+ errors: [...appResult.errors, ...pagesResult.errors],
+ routeClassifications: new Map([
+ ...appResult.routeClassifications,
+ // Pages Router wins on collision (same pattern in both routers)
+ ...pagesResult.routeClassifications,
+ ]),
+ };
+ } else {
+ // Pages Router only
+ const routes = await pagesRouter(pagesDir!);
+ const apiRoutes = await apiRouter(pagesDir!);
+ return await staticExportPages({
+ server,
+ routes,
+ apiRoutes,
+ pagesDir: pagesDir!,
+ outDir,
+ config,
+ });
+ }
+ } finally {
+ await server.close();
+ }
+}
+
+// -------------------------------------------------------------------
+// Pre-render static pages (after production build)
+// -------------------------------------------------------------------
+
+export interface PrerenderOptions {
+ root: string;
+ distDir?: string;
+ /** Pre-resolved next.config.js. If omitted, loaded from root. */
+ config?: ResolvedNextConfig;
+}
+
+export interface PrerenderResult {
+ pageCount: number;
+ files: string[];
+ warnings: string[];
+ skipped: string[];
+ /** Route pattern → runtime-confirmed classification. Primary source for printBuildReport. */
+ routeClassifications: Map;
+}
+
+/**
+ * Pre-render static pages after a production build.
+ *
+ * Detects static routes via source-file inspection (regex-based, no dev
+ * server), starts a temporary production server, fetches each static page,
+ * and writes the HTML to dist/server/pages/.
+ *
+ * Only runs for Pages Router builds. App Router builds skip pre-rendering
+ * because the App Router prod server delegates entirely to the RSC handler
+ * (which manages its own middleware, auth, and streaming pipeline).
+ */
+export async function prerenderStaticPages(options: PrerenderOptions): Promise {
+ const { root } = options;
+ const distDir = options.distDir ?? path.join(root, "dist");
+
+ // Load config to get trailingSlash (and other settings). Reuse caller's
+ // resolved config when provided to avoid a redundant disk read.
+ let config: ResolvedNextConfig;
+ if (options.config) {
+ config = options.config;
+ } else {
+ config = await resolveNextConfig(await loadNextConfig(root));
+ }
+
+ const result: PrerenderResult = {
+ pageCount: 0,
+ files: [],
+ warnings: [],
+ skipped: [],
+ routeClassifications: new Map(),
+ };
+
+ // Bail if dist/ doesn't exist
+ if (!fs.existsSync(distDir)) {
+ result.warnings.push("dist/ directory not found — run `vinext build` first");
+ return result;
+ }
+
+ // Detect router type from build output
+ const appRouterEntry = path.join(distDir, "server", "index.js");
+ const pagesRouterEntry = path.join(distDir, "server", "entry.js");
+ const isAppRouter = fs.existsSync(appRouterEntry);
+ const isPagesRouter = fs.existsSync(pagesRouterEntry);
+
+ if (!isAppRouter && !isPagesRouter) {
+ result.warnings.push("No server entry found in dist/ — cannot detect router type");
+ return result;
+ }
+
+ // App Router prod server delegates entirely to the RSC handler which manages
+ // its own middleware, auth, and streaming pipeline. Pre-rendered HTML files
+ // would never be served, so skip pre-rendering for pure App Router builds.
+ // Hybrid projects (app/ + pages/) still have a Pages Router entry that CAN
+ // serve pre-rendered pages, so only bail out when Pages Router is absent.
+ if (isAppRouter && !isPagesRouter) {
+ return result;
+ }
+
+ // Collect static routes via source-file inspection (no dev server needed).
+ // We scan the filesystem for routes, then read each source file to detect
+ // server-side exports. This avoids spinning up a Vite dev server just for
+ // route classification. Dynamic routes are skipped since they need
+ // getStaticPaths execution to enumerate param values.
+ const collected = await collectStaticRoutesFromSource(root);
+ result.skipped.push(...collected.skipped);
+ // Seed routeClassifications with all classifications from source inspection.
+ // Successfully pre-rendered routes will be upgraded to "static" below.
+ for (const [pattern, cls] of collected.routeClassifications) {
+ result.routeClassifications.set(pattern, cls);
+ }
+
+ 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;
+ if (!addr || addr.port === 0) {
+ await new Promise((resolve) => server.close(() => resolve()));
+ throw new Error("Production server failed to bind to a port for pre-rendering");
+ }
+ const port = addr.port;
+
+ try {
+ const pagesOutDir = path.join(distDir, "server", "pages");
+ fs.mkdirSync(pagesOutDir, { recursive: true });
+
+ for (const urlPath of staticUrls) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 10_000);
+ try {
+ const res = await fetch(`http://127.0.0.1:${port}${urlPath}`, {
+ signal: controller.signal,
+ });
+
+ if (!res.ok) {
+ result.skipped.push(urlPath);
+ // Cancel the body without awaiting — avoids throwing an AbortError
+ // (which would fall into the outer catch and double-count this URL in
+ // result.skipped) if the AbortController already fired.
+ res.body?.cancel();
+ continue;
+ }
+
+ 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})`);
+ res.body?.cancel();
+ continue;
+ }
+ const html = await res.text();
+ const outputPath = getOutputPath(urlPath, config.trailingSlash, pagesOutDir);
+ const fullPath = path.join(pagesOutDir, outputPath);
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
+ fs.writeFileSync(fullPath, html, "utf-8");
+
+ result.files.push(outputPath);
+ result.pageCount++;
+ // Key by urlPath (= route.pattern for non-dynamic routes, which are the
+ // only ones pre-rendered here). If dynamic route pre-rendering is ever
+ // 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" });
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ result.skipped.push(`${urlPath} (error: ${msg})`);
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+ } finally {
+ await new Promise((resolve) => server.close(() => resolve()));
+ }
+
+ return result;
+}
+
+/**
+ * Lightweight route collection for pre-rendering via source-file inspection.
+ *
+ * Scans the pages/ directory and reads each source file to detect exports
+ * like getServerSideProps and revalidate, without starting a Vite dev server.
+ * Dynamic routes are skipped (they need getStaticPaths execution).
+ *
+ * **Source tree required at pre-render time.** This function reads the original
+ * TypeScript/JSX source files (e.g. `pages/about.tsx`) to detect server-side
+ * exports. If the source tree is absent at deploy time — such as in a Docker
+ * multi-stage build where only `dist/` is copied — pre-rendering will silently
+ * produce zero routes and return early. Ensure the pages/ source directory is
+ * present in any environment where pre-rendering should run.
+ */
+async function collectStaticRoutesFromSource(root: string): Promise {
+ const pagesDirCandidates = [path.join(root, "pages"), path.join(root, "src", "pages")];
+ const pagesDir = pagesDirCandidates.find((d) => fs.existsSync(d));
+ if (!pagesDir) return { urls: [], skipped: [], routeClassifications: new Map() };
+
+ const routes = await pagesRouter(pagesDir);
+ const urls: string[] = [];
+ const skipped: string[] = [];
+ const routeClassifications: Map = new Map();
+
+ for (const route of routes) {
+ const routeName = path.basename(route.filePath, path.extname(route.filePath));
+ if (routeName.startsWith("_")) continue;
+
+ if (route.isDynamic) {
+ skipped.push(`${route.pattern} (dynamic)`);
+ // Classify as "unknown", not "ssr": skipped due to unenumerable params,
+ // not because the route uses SSR APIs.
+ routeClassifications.set(route.pattern, { type: "unknown" });
+ continue;
+ }
+
+ try {
+ // Use classifyPagesRoute as the single source of truth — it already
+ // detects getServerSideProps (→ ssr), getStaticProps+revalidate (→ isr),
+ // re-exported getStaticProps (→ isr, conservative), and plain static pages.
+ const classification = classifyPagesRoute(route.filePath);
+ routeClassifications.set(route.pattern, classification);
+
+ if (classification.type === "ssr") {
+ skipped.push(`${route.pattern} (getServerSideProps)`);
+ continue;
+ }
+
+ if (classification.type === "isr") {
+ skipped.push(
+ `${route.pattern} (ISR — getStaticProps with revalidate${classification.revalidate != null ? `=${classification.revalidate}` : ""})`,
+ );
+ continue;
+ }
+
+ urls.push(route.pattern);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ skipped.push(`${route.pattern} (classification error: ${msg})`);
+ }
+ }
+
+ return { urls, skipped, routeClassifications };
+}
+
+/**
+ * Classify all routes in the project via static source-file analysis.
+ *
+ * Covers both App Router (app/) and Pages Router (pages/). Does NOT start a
+ * dev server or execute any page modules — all classification is done by
+ * reading source files with regex-based analysis. This means:
+ *
+ * - `export const dynamic = "force-dynamic"` → ssr
+ * - `export const revalidate = 60` → isr
+ * - `getServerSideProps` → ssr
+ * - `getStaticProps` with no revalidate → static
+ * - dynamic URL pattern with no explicit config → unknown (conservative)
+ * - re-exported `getStaticProps` → isr (conservative)
+ *
+ * Limitation: cannot detect implicit dynamic behaviour from runtime API
+ * calls (`headers()`, `cookies()`, etc.) that are invisible to static
+ * analysis. Routes without explicit config are classified as "unknown"
+ * rather than "static".
+ *
+ * This is the same analysis used internally by `prerenderStaticPages` and
+ * `printBuildReport`. Exposing it as a public function lets callers build
+ * the route map cheaply — without pre-rendering — and then decide what to
+ * do with that information.
+ */
+export async function classifyRoutesFromSource(
+ root: string,
+): Promise