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> { + const routeClassifications: Map = new Map(); + + // ── App Router ──────────────────────────────────────────────────────────── + const appDirCandidates = [path.join(root, "app"), path.join(root, "src", "app")]; + const appDir = appDirCandidates.find((d) => fs.existsSync(d)); + + if (appDir) { + const routes = await appRouter(appDir); + for (const route of routes) { + routeClassifications.set( + route.pattern, + classifyAppRoute(route.pagePath, route.routePath, route.isDynamic), + ); + } + } + + // ── Pages Router ────────────────────────────────────────────────────────── + const pagesDirCandidates = [path.join(root, "pages"), path.join(root, "src", "pages")]; + const pagesDir = pagesDirCandidates.find((d) => fs.existsSync(d)); + + if (pagesDir) { + const [pageRoutes, apiRoutes] = await Promise.all([pagesRouter(pagesDir), apiRouter(pagesDir)]); + + for (const route of apiRoutes) { + routeClassifications.set(route.pattern, { type: "api" }); + } + + for (const route of pageRoutes) { + const routeName = path.basename(route.filePath, path.extname(route.filePath)); + if (routeName.startsWith("_")) continue; + routeClassifications.set(route.pattern, classifyPagesRoute(route.filePath)); + } + } + + return routeClassifications; +} + +interface CollectedRoutes { + urls: string[]; + skipped: string[]; + routeClassifications: Map; +} diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 88598bb0a..5669e8647 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -415,7 +415,80 @@ async function buildApp() { ); } - await printBuildReport({ root: process.cwd() }); + // ── Static export (output: "export") ────────────────────────── + // Dynamic import: lazily load config resolution only when needed. + // vinext dev / vinext start don't reach this code path, so keeping + // this as a dynamic import avoids paying the parse cost on every CLI startup. + const { loadNextConfig, resolveNextConfig } = await import( + /* @vite-ignore */ "./config/next-config.js" + ); + const rawConfig = await loadNextConfig(process.cwd()); + const resolvedConfig = await resolveNextConfig(rawConfig); + + if (resolvedConfig.output === "export") { + console.log("\n Static export (output: 'export')...\n"); + + const { runStaticExport } = await import(/* @vite-ignore */ "./build/static-export.js"); + + const result = await runStaticExport({ root: process.cwd(), config: resolvedConfig }); + + if (result.warnings.length > 0) { + for (const w of result.warnings) console.log(` Warning: ${w}`); + } + if (result.errors.length > 0) { + for (const e of result.errors) console.error(` Error (${e.route}): ${e.error}`); + } + + console.log(`\n Exported ${result.pageCount} page(s) to out/\n`); + + if (result.errors.length > 0) { + process.exit(1); + } + + await printBuildReport({ root: process.cwd(), knownRoutes: result.routeClassifications }); + + console.log(" Static export complete. Serve with any static file server:\n"); + console.log(" npx serve out\n"); + return; + } + + // ── Pre-render static pages (non-export builds) ──────────────── + const { prerenderStaticPages, classifyRoutesFromSource } = await import( + /* @vite-ignore */ "./build/static-export.js" + ); + + const prerenderResult = await prerenderStaticPages({ + root: process.cwd(), + config: resolvedConfig, + }); + + if (prerenderResult.warnings.length > 0) { + for (const w of prerenderResult.warnings) console.log(` Warning: ${w}`); + } + + // prerenderStaticPages returns early for App Router builds (pageCount 0, no skipped). + // Only print pre-render progress when the function actually ran. + if (prerenderResult.pageCount > 0 || prerenderResult.skipped.length > 0) { + if (prerenderResult.skipped.length > 0) { + console.log(` Skipped ${prerenderResult.skipped.length} route(s) (dynamic or errored)`); + } + console.log(`\n Pre-rendered ${prerenderResult.pageCount} static page(s)\n`); + } + + // 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 = + prerenderResult.routeClassifications.size > 0 + ? prerenderResult.routeClassifications + : await classifyRoutesFromSource(process.cwd()); + + await printBuildReport({ + root: process.cwd(), + knownRoutes, + }); + console.log("\n Build complete. Run `vinext start` to start the production server.\n"); } @@ -428,6 +501,23 @@ async function start() { mode: "production", }); + // Reject static export builds — they don't need a production server. + // A static export produces out/ but no dist/server/entry.js (the Pages + // Router production entry). Check for the absence of the entry specifically + // rather than relying on the coarser "dist/ exists" heuristic, which + // false-positives when the project has an unrelated out/ directory (e.g. + // TypeScript's outDir: "out"). + const distServerEntry = path.resolve(process.cwd(), "dist", "server", "entry.js"); + 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) { + console.error('\n "vinext start" does not work with "output: export" configuration.'); + console.error(" Use a static file server instead:\n"); + console.error(" npx serve out\n"); + process.exit(1); + } + const port = parsed.port ?? parseInt(process.env.PORT ?? "3000", 10); const host = parsed.hostname ?? "0.0.0.0"; diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 0736a2a14..e0dd1bc5a 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -29,6 +29,9 @@ const _routeTriePath = fileURLToPath(new URL("../routing/route-trie.js", import. /\\/g, "/", ); +const _routeValidationPath = fileURLToPath( + new URL("../routing/route-validation.js", import.meta.url), +).replace(/\\/g, "/"); const _pagesI18nPath = fileURLToPath(new URL("../server/pages-i18n.js", import.meta.url)).replace( /\\/g, "/", @@ -275,9 +278,10 @@ import { safeJsonStringify } from "vinext/html"; import { decode as decodeQueryString } from "node:querystring"; 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, "/"))}; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from ${JSON.stringify(_requestContextShimPath)}; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from ${JSON.stringify(_routeTriePath)}; +import { patternToNextFormat } from ${JSON.stringify(_routeValidationPath)}; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { resolvePagesI18nRequest } from ${JSON.stringify(_pagesI18nPath)}; ${instrumentationImportCode} @@ -421,13 +425,6 @@ function parseQuery(url) { return q; } -function patternToNextFormat(pattern) { - return pattern - .replace(/:([\\w]+)\\*/g, "[[...$1]]") - .replace(/:([\\w]+)\\+/g, "[...$1]") - .replace(/:([\\w]+)/g, "[$1]"); -} - function collectAssetTags(manifest, moduleIds) { // Fall back to embedded manifest (set by vinext:cloudflare-build for Workers) const m = (manifest && Object.keys(manifest).length > 0) diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 951ec231b..e367c3def 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -313,6 +313,62 @@ function tryServeStatic( return true; } +/** + * Check if a pre-rendered HTML file exists for a given pathname. + * Returns the absolute file path if found, null otherwise. + * + * Checks both `/pathname.html` and `/pathname/index.html` to support + * both trailingSlash modes. Pre-rendered files are written during + * `vinext build` to dist/server/pages/. + */ +function resolvePrerenderedHtml(dir: string, pathname: string): string | null { + // Normalize: "/" → "index", "/about" → "about", "/blog/post" → "blog/post" + // + // Note on the root path ("/"): + // normalized = "index" + // directPath = /index.html ← written by getOutputPath for trailingSlash:false + // indexPath = /index/index.html ← this is WRONG for "/" + // + // The directPath branch handles "/" correctly because getOutputPath always + // writes the root as "index.html". The indexPath branch (trailingSlash:true + // fallback) would look for "index/index.html" which is never written — it is + // effectively dead for the root path. Do not change the normalized value for + // "/" without also updating getOutputPath and the indexPath lookup below. + const normalized = pathname === "/" ? "index" : pathname.replace(/^\//, "").replace(/\/$/, ""); + + // Guard against directory traversal (defence-in-depth; pathname is always a + // request URL that starts with "/" so path.resolve can't escape this dir). + const resolvedDir = path.resolve(dir); + + // trailingSlash:false → written as .html + const directPath = path.join(dir, `${normalized}.html`); + if ( + path.resolve(directPath).startsWith(resolvedDir + path.sep) && + fs.existsSync(directPath) && + fs.statSync(directPath).isFile() + ) { + return directPath; + } + + // For the root path "/" the only valid pre-rendered file is index.html + // (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; + + // trailingSlash:true → written as /index.html + const indexPath = path.join(dir, normalized, "index.html"); + if ( + path.resolve(indexPath).startsWith(resolvedDir + path.sep) && + fs.existsSync(indexPath) && + fs.statSync(indexPath).isFile() + ) { + return indexPath; + } + + return null; +} + /** * Resolve the host for a request, ignoring X-Forwarded-Host to prevent * host header poisoning attacks (open redirects, cache poisoning). @@ -755,6 +811,11 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { } : undefined; + // Pre-rendered HTML directory (written by `vinext build` pre-rendering step). + // Check existence once at startup to avoid per-request fs.existsSync calls. + const pagesPrerenderedDir = path.join(path.dirname(serverEntryPath), "pages"); + const hasPrerenderedPages = fs.existsSync(pagesPrerenderedDir); + // Load the SSR manifest (maps module URLs to client asset URLs) let ssrManifest: Record = {}; const manifestPath = path.join(clientDir, ".vite", "ssr-manifest.json"); @@ -1111,6 +1172,56 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { } } + // ── 9b. Pre-rendered HTML (after all rewrites) ─────────────── + // Serve build-time pre-rendered static pages. This sits after + // afterFiles rewrites (step 9) so that rewrites are applied first, + // matching Next.js execution order where pre-rendered pages are + // logically equivalent to page rendering. API routes (step 8) run + // first so an api.html pre-rendered file cannot shadow real API routes. + const pagesPrerenderedFile = hasPrerenderedPages + ? resolvePrerenderedHtml(pagesPrerenderedDir, resolvedPathname) + : null; + if (pagesPrerenderedFile) { + const html = await fs.promises.readFile(pagesPrerenderedFile, "utf-8"); + // Strip any Location header that middleware may have set. A Location + // header is only meaningful alongside a 3xx redirect status; spreading + // it onto a 200 pre-rendered response would be semantically wrong and + // could cause browsers or CDNs to follow a redirect they should not. + const safeMiddlewareHeaders = Object.fromEntries( + Object.entries(middlewareHeaders).filter(([k]) => k.toLowerCase() !== "location"), + ); + const prerenderedHeaders: Record = { + ...safeMiddlewareHeaders, + // Conservative cache TTL for pre-rendered pages. A year-long cache + // (s-maxage=31536000) would be ideal for truly static pages, but + // collectStaticRoutesFromSource has known false-negative cases for + // ISR detection (re-exported getStaticProps, variable-based revalidate). + // If an ISR page with revalidate:60 slips through, a year-long CDN pin + // 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. + // 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", + }; + // Always respond 200 for pre-rendered pages. middlewareRewriteStatus + // records the status from a NextResponse.rewrite() call — that value + // is intended for use when the page is rendered dynamically, not when + // we short-circuit with a pre-built file. A 403/302/etc. rewrite status + // paired with a 200 HTML body would be semantically wrong. + sendCompressed( + req, + res, + html, + "text/html; charset=utf-8", + 200, + prerenderedHeaders, + compress, + ); + return; + } + // ── 10. SSR page rendering ──────────────────────────────────── let response: Response | undefined; if (typeof renderPage === "function") { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 889563930..ea7a388ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -945,6 +945,27 @@ importers: specifier: 'catalog:' version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1) + tests/fixtures/hybrid-basic: + dependencies: + '@vitejs/plugin-rsc': + specifier: ^0.5.19 + version: 0.5.20(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)) + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + react-server-dom-webpack: + specifier: ^19.2.4 + version: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vinext: + specifier: workspace:* + version: link:../../../packages/vinext + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1) + tests/fixtures/pages-basic: dependencies: react: diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 8682a2cb1..026bf643a 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -17945,9 +17945,10 @@ import { safeJsonStringify } from "vinext/html"; import { decode as decodeQueryString } from "node:querystring"; 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 "/packages/vinext/src/config/config-matchers.js"; +import { parseCookies, sanitizeDestination as sanitizeDestinationLocal } from "/packages/vinext/src/config/config-matchers.js"; import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "/packages/vinext/src/shims/request-context.js"; import { buildRouteTrie as _buildRouteTrie, trieMatch as _trieMatch } from "/packages/vinext/src/routing/route-trie.js"; +import { patternToNextFormat } from "/packages/vinext/src/routing/route-validation.js"; import { reportRequestError as _reportRequestError } from "vinext/instrumentation"; import { resolvePagesI18nRequest } from "/packages/vinext/src/server/pages-i18n.js"; import * as _instrumentation from "/tests/fixtures/pages-basic/instrumentation.ts"; @@ -18184,13 +18185,6 @@ function parseQuery(url) { return q; } -function patternToNextFormat(pattern) { - return pattern - .replace(/:([\\w]+)\\*/g, "[[...$1]]") - .replace(/:([\\w]+)\\+/g, "[...$1]") - .replace(/:([\\w]+)/g, "[$1]"); -} - function collectAssetTags(manifest, moduleIds) { // Fall back to embedded manifest (set by vinext:cloudflare-build for Workers) const m = (manifest && Object.keys(manifest).length > 0) diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index eead11c3a..7a8430dc2 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1818,7 +1818,6 @@ describe("App Router Static export", () => { const result = await staticExportApp({ baseUrl, routes, - appDir, server, outDir: exportDir, config, @@ -1854,7 +1853,7 @@ describe("App Router Static export", () => { expect(html404).toContain("Page Not Found"); }); - it("reports errors for dynamic routes without generateStaticParams", async () => { + it("warns and skips dynamic routes without generateStaticParams", async () => { const { staticExportApp } = await import("../packages/vinext/src/build/static-export.js"); const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js"); @@ -1888,14 +1887,14 @@ describe("App Router Static export", () => { const result = await staticExportApp({ baseUrl, routes: fakeRoutes, - appDir: path.resolve(APP_FIXTURE_DIR, "app"), server, outDir: tempDir, config, }); - // Should have an error about missing generateStaticParams - expect(result.errors.some((e) => e.error.includes("generateStaticParams"))).toBe(true); + // Should warn (not error) about missing generateStaticParams + expect(result.errors).toHaveLength(0); + expect(result.warnings.some((w) => w.includes("generateStaticParams"))).toBe(true); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -1935,7 +1934,6 @@ describe("App Router Static export", () => { const result = await staticExportApp({ baseUrl, routes: fakeRoutes, - appDir: path.resolve(APP_FIXTURE_DIR, "app"), server, outDir: tempDir, config, diff --git a/tests/build-prerender.test.ts b/tests/build-prerender.test.ts new file mode 100644 index 000000000..f7a01ca94 --- /dev/null +++ b/tests/build-prerender.test.ts @@ -0,0 +1,272 @@ +/** + * Tests for Phase 2 of static pre-rendering. + * + * Tests: + * 1. Production server serving pre-rendered HTML from dist/server/pages/ + * 2. prerenderStaticPages() function existence and return type + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import type { Server } from "node:http"; + +const PAGES_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/pages-basic"); + +// ─── Production server — serves pre-rendered HTML ───────────────────────────── + +const outDir = path.resolve(PAGES_FIXTURE, "dist"); +const serverEntryPath = path.join(outDir, "server", "entry.js"); +const fixtureBuilt = fs.existsSync(serverEntryPath); +if (!fixtureBuilt) { + console.warn( + `[build-prerender] fixture not built — skipping production-server tests. ` + + `Run \`pnpm build\` inside ${PAGES_FIXTURE} to enable them.`, + ); +} + +// Sentinel: fail loudly in CI when the fixture hasn't been built instead of +// silently passing an empty suite. The skipIf block below is for local dev +// convenience only — CI must always build the fixture before running this file +// (the build step runs `pnpm build` inside the fixture directory). +it("fixture must be built before running pre-render tests", () => { + if (!fixtureBuilt) { + throw new Error( + `Pre-render fixture not built.\n` + + ` Local dev: run \`pnpm build\` inside ${PAGES_FIXTURE}\n` + + ` CI: ensure the build step runs before the test step`, + ); + } +}); + +describe.skipIf(!fixtureBuilt)("Production server — serves pre-rendered HTML", () => { + const pagesDir = path.join(outDir, "server", "pages"); + const prerenderedFile = path.join(pagesDir, "prerendered-test.html"); + let server: Server; + let baseUrl: string; + + beforeAll(async () => { + // Clear any stale files left by a previous failed run before creating test fixtures. + fs.rmSync(pagesDir, { recursive: true, force: true }); + // Create a fake pre-rendered HTML file at dist/server/pages/prerendered-test.html + fs.mkdirSync(pagesDir, { recursive: true }); + fs.writeFileSync( + prerenderedFile, + `Pre-rendered
Pre-rendered test content
`, + "utf-8", + ); + + try { + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + server = await startProdServer({ + port: 0, + host: "127.0.0.1", + outDir, + }); + const addr = server.address() as { port: number }; + baseUrl = `http://127.0.0.1:${addr.port}`; + } catch (e) { + // Clean up test files if server startup fails so subsequent runs aren't affected. + // Use recursive removal for consistency with afterAll — targeted cleanup can miss + // files left by earlier failed tests. + fs.rmSync(pagesDir, { recursive: true, force: true }); + throw e; + } + }); + + afterAll(async () => { + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } + // Recursively clean all test-created files under pagesDir. + // Using recursive removal protects against stale files left behind if a + // test fails between writeFileSync and its own finally block (e.g. the + // nested pre-rendered HTML test or the index.html test). + if (fs.existsSync(pagesDir)) { + fs.rmSync(pagesDir, { recursive: true, force: true }); + } + }); + + it("serves pre-rendered HTML for /prerendered-test", async () => { + const res = await fetch(`${baseUrl}/prerendered-test`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Pre-rendered test content"); + }); + + it("serves pre-rendered HTML with text/html content type", async () => { + const res = await fetch(`${baseUrl}/prerendered-test`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + }); + + it("serves pre-rendered HTML with Cache-Control header for CDN caching", async () => { + const res = await fetch(`${baseUrl}/prerendered-test`); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("s-maxage=3600, stale-while-revalidate=86400"); + await res.text(); // consume body + }); + + it("always serves pre-rendered HTML with status 200, ignoring middleware rewrite status", async () => { + // Pre-rendered files are served unconditionally as 200. A middleware + // NextResponse.rewrite() with a non-200 status is only meaningful when + // the page is rendered dynamically — forwarding that status alongside a + // cached HTML body would be semantically wrong. + const res = await fetch(`${baseUrl}/prerendered-test`); + expect(res.status).toBe(200); + await res.text(); + }); + + it("falls back to SSR when no pre-rendered file exists", async () => { + // /about is a real page in pages-basic but has no pre-rendered file. + // Assert the file is absent so this test is not vacuous. + const aboutFile = path.join(pagesDir, "about.html"); + expect( + fs.existsSync(aboutFile), + "about.html must not exist in pagesDir for this test to be meaningful", + ).toBe(false); + + const res = await fetch(`${baseUrl}/about`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("About"); + }); + + it("serves nested pre-rendered HTML (e.g. /blog/hello-world)", async () => { + // Create a nested pre-rendered file simulating a dynamic route + const nestedDir = path.join(pagesDir, "blog"); + const nestedFile = path.join(nestedDir, "hello-world.html"); + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync( + nestedFile, + `Blog post content`, + "utf-8", + ); + + try { + const res = await fetch(`${baseUrl}/blog/hello-world`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Blog post content"); + } finally { + fs.rmSync(nestedFile); + if (fs.existsSync(nestedDir) && fs.readdirSync(nestedDir).length === 0) { + fs.rmdirSync(nestedDir); + } + } + }); + + it("serves pre-rendered index.html for /", async () => { + // This test creates the file AFTER the server starts. It works because + // resolvePrerenderedHtml calls fs.existsSync on every request rather than + // caching the directory listing at startup — so new files are picked up + // immediately without a server restart. + const indexFile = path.join(pagesDir, "index.html"); + fs.writeFileSync( + indexFile, + `Pre-rendered home`, + "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); + } + }); + + it("serves pre-rendered about/index.html for /about (trailingSlash:true layout)", async () => { + // When trailingSlash is true, getOutputPath writes pages as /index.html + // instead of .html. resolvePrerenderedHtml checks both patterns, so + // about/index.html should be served at /about regardless of config. + const aboutDir = path.join(pagesDir, "about"); + const aboutFile = path.join(aboutDir, "index.html"); + fs.mkdirSync(aboutDir, { recursive: true }); + fs.writeFileSync( + aboutFile, + `Pre-rendered about (trailing slash)`, + "utf-8", + ); + + try { + const res = await fetch(`${baseUrl}/about`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Pre-rendered about (trailing slash)"); + } finally { + fs.rmSync(aboutFile); + if (fs.existsSync(aboutDir) && fs.readdirSync(aboutDir).length === 0) { + fs.rmdirSync(aboutDir); + } + } + }); +}); + +// ─── prerenderStaticPages — function exists ─────────────────────────────────── + +describe("prerenderStaticPages — function exists", () => { + it("prerenderStaticPages is exported as a function", async () => { + const mod = await import("../packages/vinext/src/build/static-export.js"); + expect(typeof mod.prerenderStaticPages).toBe("function"); + }); + + it.skipIf(!fixtureBuilt)("PrerenderResult type is returned", async () => { + const { prerenderStaticPages } = await import("../packages/vinext/src/build/static-export.js"); + // Call with the pages-basic fixture which has a built dist/ + const result = await prerenderStaticPages({ root: PAGES_FIXTURE }); + expect(result).toHaveProperty("pageCount"); + expect(result).toHaveProperty("files"); + expect(result).toHaveProperty("warnings"); + expect(result).toHaveProperty("skipped"); + // Verify the function actually ran and produced meaningful output — not + // just an empty early-return. The pages-basic fixture has at least one + // static page (index), so pageCount must be > 0. + expect(result.pageCount).toBeGreaterThan(0); + expect(result.files.length).toBeGreaterThan(0); + }); +}); + +// ─── getOutputPath — path-traversal guard ──────────────────────────────────── + +describe("getOutputPath — path-traversal guard", () => { + // URL inputs always start with '/', so path.posix.normalize can never + // produce a path above '/' (e.g. "/../etc/passwd" normalizes to "/etc/passwd"). + // The boundary check therefore prevents traversal for non-URL-derived paths + // such as those coming directly from generateStaticParams/getStaticPaths on + // Windows (where path.sep is '\' and path.resolve uses drive roots). + // We verify the safe paths and the fact that suspicious-looking inputs are + // normalized to safe outputs rather than throwing. + + it("normalizes traversal segments — /../etc/passwd maps to /etc/passwd within outDir", async () => { + const { getOutputPath } = await import("../packages/vinext/src/build/static-export.js"); + // path.posix.normalize("/../etc/passwd") === "/etc/passwd", so this + // resolves to /tmp/out/etc/passwd.html — within bounds. + expect(getOutputPath("/../etc/passwd", false, "/tmp/out")).toBe("etc/passwd.html"); + }); + + it("normalizes multi-level traversal — /../../secret maps to /secret within outDir", async () => { + const { getOutputPath } = await import("../packages/vinext/src/build/static-export.js"); + // path.posix.normalize("/../../secret") === "/secret", so this + // resolves to /tmp/out/secret.html — within bounds. + expect(getOutputPath("/../../secret", false, "/tmp/out")).toBe("secret.html"); + }); + + it("accepts normal paths within the output directory", async () => { + const { getOutputPath } = await import("../packages/vinext/src/build/static-export.js"); + expect(getOutputPath("/about", false, "/tmp/out")).toBe("about.html"); + expect(getOutputPath("/blog/hello-world", false, "/tmp/out")).toBe("blog/hello-world.html"); + expect(getOutputPath("/", false, "/tmp/out")).toBe("index.html"); + }); + + it("rejects backslash-containing paths", async () => { + const { getOutputPath } = await import("../packages/vinext/src/build/static-export.js"); + expect(() => getOutputPath("..\\secret", false, "/tmp/out")).toThrow( + "escapes the output directory", + ); + expect(() => getOutputPath("\\etc\\passwd", false, "/tmp/out")).toThrow( + "escapes the output directory", + ); + }); +}); diff --git a/tests/build-static-export.test.ts b/tests/build-static-export.test.ts new file mode 100644 index 000000000..d1197deb2 --- /dev/null +++ b/tests/build-static-export.test.ts @@ -0,0 +1,222 @@ +/** + * Tests for runStaticExport() — the high-level orchestrator that + * takes a project root, starts a temporary Vite dev server, scans routes, + * runs the appropriate static export (Pages or App Router), and returns + * a StaticExportResult. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import type { StaticExportResult } from "../packages/vinext/src/build/static-export.js"; +import { runStaticExport } from "../packages/vinext/src/build/static-export.js"; + +const PAGES_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/pages-basic"); +const HYBRID_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/hybrid-basic"); + +// ─── Pages Router ──────────────────────────────────────────────────────────── + +describe("runStaticExport — Pages Router", () => { + let result: StaticExportResult; + const outDir = path.resolve(PAGES_FIXTURE, "out-run-static-pages"); + + beforeAll(async () => { + result = await runStaticExport({ + root: PAGES_FIXTURE, + outDir, + // trailingSlash: false → pages are written as about.html, not about/index.html + configOverride: { output: "export", trailingSlash: false }, + }); + }, 60_000); + + afterAll(() => { + fs.rmSync(outDir, { recursive: true, force: true }); + }); + + it("produces HTML files in outDir", () => { + expect(result.pageCount).toBeGreaterThan(0); + expect(result.files.length).toBeGreaterThan(0); + + // Every listed file should physically exist on disk + for (const file of result.files) { + const fullPath = path.join(outDir, file); + expect(fs.existsSync(fullPath), `expected ${file} to exist`).toBe(true); + } + }); + + it("generates index.html", () => { + expect(result.files).toContain("index.html"); + expect(fs.existsSync(path.join(outDir, "index.html"))).toBe(true); + }); + + it("generates about.html", () => { + expect(result.files).toContain("about.html"); + expect(fs.existsSync(path.join(outDir, "about.html"))).toBe(true); + }); + + it("generates 404.html", () => { + expect(result.files).toContain("404.html"); + expect(fs.existsSync(path.join(outDir, "404.html"))).toBe(true); + }); + + it("expands dynamic routes via getStaticPaths", () => { + // pages-basic/pages/blog/[slug].tsx defines hello-world and getting-started + expect(result.files).toContain("blog/hello-world.html"); + expect(result.files).toContain("blog/getting-started.html"); + }); + + it("reports errors for getServerSideProps pages, not crashes", () => { + // pages-basic has pages that use getServerSideProps (e.g. ssr.tsx). + // These should appear as structured errors, not thrown exceptions. + const gsspErrors = result.errors.filter((e) => e.error.includes("getServerSideProps")); + expect(gsspErrors.length).toBeGreaterThan(0); + }); + + it("returns warnings array (possibly empty)", () => { + expect(Array.isArray(result.warnings)).toBe(true); + }); +}); + +// ─── App Router ────────────────────────────────────────────────────────────── +// +// Uses hybrid-basic fixture (app/ + pages/) but only exercises App Router +// assertions here. The minimal fixture has no external deps so App Router +// pages render successfully, unlike the larger app-basic fixture where +// app/ pages return 500 due to its complex dependency setup. + +describe("runStaticExport — App Router", () => { + let result: StaticExportResult; + const outDir = path.resolve(HYBRID_FIXTURE, "out-run-static-app"); + + beforeAll(async () => { + result = await runStaticExport({ + root: HYBRID_FIXTURE, + outDir, + // trailingSlash: false → pages are written as about.html, not about/index.html + configOverride: { output: "export", trailingSlash: false }, + }); + }, 60_000); + + afterAll(() => { + fs.rmSync(outDir, { recursive: true, force: true }); + }); + + it("produces HTML files in outDir", () => { + expect(result.pageCount).toBeGreaterThan(0); + expect(result.files.length).toBeGreaterThan(0); + + for (const file of result.files) { + const fullPath = path.join(outDir, file); + expect(fs.existsSync(fullPath), `expected ${file} to exist`).toBe(true); + } + }); + + it("generates index.html", () => { + expect(result.files).toContain("index.html"); + expect(fs.existsSync(path.join(outDir, "index.html"))).toBe(true); + }); + + it("generates about.html", () => { + expect(result.files).toContain("about.html"); + expect(fs.existsSync(path.join(outDir, "about.html"))).toBe(true); + }); + + it("returns no errors for the core static pages", () => { + // index and about are plain server components — no dynamic API, no errors expected. + const coreRouteErrors = result.errors.filter((e) => e.route === "/" || e.route === "/about"); + expect(coreRouteErrors).toEqual([]); + }); + + it("returns warnings array (possibly empty)", () => { + expect(Array.isArray(result.warnings)).toBe(true); + }); +}); + +// ─── Hybrid (app/ + pages/) ─────────────────────────────────────────────────── + +describe("runStaticExport — Hybrid (app/ + pages/)", () => { + // hybrid-basic has both app/ and pages/ directories. + // app/page.tsx and app/about/page.tsx are plain server components. + // pages/legacy.tsx is a plain static page (no getServerSideProps). + let result: StaticExportResult; + const outDir = path.resolve(HYBRID_FIXTURE, "out-run-static-hybrid"); + + beforeAll(async () => { + result = await runStaticExport({ + root: HYBRID_FIXTURE, + outDir, + configOverride: { output: "export", trailingSlash: false }, + }); + }, 60_000); + + afterAll(() => { + fs.rmSync(outDir, { recursive: true, force: true }); + }); + + it("exports App Router pages", () => { + expect(result.files).toContain("index.html"); + expect(result.files).toContain("about.html"); + expect(fs.existsSync(path.join(outDir, "about.html"))).toBe(true); + }); + + it("exports Pages Router pages", () => { + expect(result.files).toContain("legacy.html"); + expect(fs.existsSync(path.join(outDir, "legacy.html"))).toBe(true); + }); + + it("page count includes both routers", () => { + // At minimum: index + about from app/, legacy from pages/ + expect(result.pageCount).toBeGreaterThanOrEqual(3); + }); + + it("emits no errors for plain static pages from either router", () => { + const coreErrors = result.errors.filter( + (e) => e.route === "/" || e.route === "/about" || e.route === "/legacy", + ); + expect(coreErrors).toEqual([]); + }); + + it("does not emit the old 'pages/ is skipped' warning", () => { + const skipWarning = result.warnings.find((w) => w.includes("pages/ is skipped")); + expect(skipWarning).toBeUndefined(); + }); +}); + +// ─── trailingSlash: true ────────────────────────────────────────────────────── + +describe("runStaticExport — trailingSlash: true", () => { + let result: StaticExportResult; + const outDir = path.resolve(PAGES_FIXTURE, "out-run-static-trailing-slash"); + + beforeAll(async () => { + result = await runStaticExport({ + root: PAGES_FIXTURE, + outDir, + // trailingSlash: true → pages are written as about/index.html, not about.html + configOverride: { output: "export", trailingSlash: true }, + }); + }, 60_000); + + afterAll(() => { + fs.rmSync(outDir, { recursive: true, force: true }); + }); + + it("writes about/index.html instead of about.html", () => { + expect(result.files).toContain("about/index.html"); + expect(fs.existsSync(path.join(outDir, "about/index.html"))).toBe(true); + // about.html should NOT be present + expect(result.files).not.toContain("about.html"); + expect(fs.existsSync(path.join(outDir, "about.html"))).toBe(false); + }); + + it("writes index.html at root (unchanged for trailingSlash)", () => { + expect(result.files).toContain("index.html"); + expect(fs.existsSync(path.join(outDir, "index.html"))).toBe(true); + }); + + it("every listed file exists on disk", () => { + for (const file of result.files) { + const fullPath = path.join(outDir, file); + expect(fs.existsSync(fullPath), `expected ${file} to exist`).toBe(true); + } + }); +}); diff --git a/tests/fixtures/app-basic/app/empty-gsp/[slug]/page.tsx b/tests/fixtures/app-basic/app/empty-gsp/[slug]/page.tsx new file mode 100644 index 000000000..6e884fd31 --- /dev/null +++ b/tests/fixtures/app-basic/app/empty-gsp/[slug]/page.tsx @@ -0,0 +1,9 @@ +// Fixture for testing: generateStaticParams returning [] should produce a +// warning (not an error) and skip the route during static export. +export async function generateStaticParams() { + return []; +} + +export default function EmptyGspPage({ params }: { params: { slug: string } }) { + return
Empty GSP: {params.slug}
; +} diff --git a/tests/fixtures/hybrid-basic/app/about/page.tsx b/tests/fixtures/hybrid-basic/app/about/page.tsx new file mode 100644 index 000000000..01ba77821 --- /dev/null +++ b/tests/fixtures/hybrid-basic/app/about/page.tsx @@ -0,0 +1,8 @@ +export default function AboutPage() { + return ( +
+

About

+

App Router about page.

+
+ ); +} diff --git a/tests/fixtures/hybrid-basic/app/layout.tsx b/tests/fixtures/hybrid-basic/app/layout.tsx new file mode 100644 index 000000000..9021e82e1 --- /dev/null +++ b/tests/fixtures/hybrid-basic/app/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Hybrid Basic", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/hybrid-basic/app/page.tsx b/tests/fixtures/hybrid-basic/app/page.tsx new file mode 100644 index 000000000..cb0b26147 --- /dev/null +++ b/tests/fixtures/hybrid-basic/app/page.tsx @@ -0,0 +1,8 @@ +export default function HomePage() { + return ( +
+

Hybrid Home

+

App Router home page.

+
+ ); +} diff --git a/tests/fixtures/hybrid-basic/package.json b/tests/fixtures/hybrid-basic/package.json new file mode 100644 index 000000000..bb135649f --- /dev/null +++ b/tests/fixtures/hybrid-basic/package.json @@ -0,0 +1,13 @@ +{ + "name": "fixture-hybrid-basic", + "private": true, + "type": "module", + "dependencies": { + "@vitejs/plugin-rsc": "^0.5.19", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-server-dom-webpack": "^19.2.4", + "vinext": "workspace:*", + "vite": "^7.3.1" + } +} diff --git a/tests/fixtures/hybrid-basic/pages/legacy.tsx b/tests/fixtures/hybrid-basic/pages/legacy.tsx new file mode 100644 index 000000000..1c47b1fc1 --- /dev/null +++ b/tests/fixtures/hybrid-basic/pages/legacy.tsx @@ -0,0 +1,8 @@ +export default function LegacyPage() { + return ( +
+

Legacy Pages Router

+

This page lives in pages/ and is exported via the Pages Router.

+
+ ); +} diff --git a/tests/fixtures/hybrid-basic/tsconfig.json b/tests/fixtures/hybrid-basic/tsconfig.json new file mode 100644 index 000000000..97d26ad73 --- /dev/null +++ b/tests/fixtures/hybrid-basic/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "types": ["vite/client", "@vitejs/plugin-rsc/types"] + }, + "include": ["app", "pages", "*.ts"] +} diff --git a/tests/fixtures/hybrid-basic/vite.config.ts b/tests/fixtures/hybrid-basic/vite.config.ts new file mode 100644 index 000000000..d0a0fd505 --- /dev/null +++ b/tests/fixtures/hybrid-basic/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import vinext from "vinext"; + +export default defineConfig({ + plugins: [vinext({ appDir: import.meta.dirname })], +}); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 2d6ef990a..5e9d6d174 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -2643,6 +2643,40 @@ describe("Static export (Pages Router)", () => { } }); + it("warns and skips dynamic routes without getStaticPaths", async () => { + const { staticExportPages } = await import("../packages/vinext/src/build/static-export.js"); + const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js"); + + // Create a fake dynamic route with no getStaticPaths + const fakeRoutes = [ + { + pattern: "/fake/:id", + filePath: path.resolve(FIXTURE_DIR, "pages", "index.tsx"), + isDynamic: true, + params: ["id"], + }, + ]; + const config = await resolveNextConfig({ output: "export" }); + const tempDir = path.resolve(FIXTURE_DIR, "out-temp-pages-warn"); + + try { + const result = await staticExportPages({ + server, + routes: fakeRoutes as any, + apiRoutes: [], + pagesDir: path.resolve(FIXTURE_DIR, "pages"), + outDir: tempDir, + config, + }); + + // Should warn (not error) about missing getStaticPaths + expect(result.errors).toHaveLength(0); + expect(result.warnings.some((w) => w.includes("getStaticPaths"))).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("includes __NEXT_DATA__ in exported HTML", async () => { const indexHtml = fs.readFileSync(path.join(exportDir, "index.html"), "utf-8"); expect(indexHtml).toContain("__NEXT_DATA__"); diff --git a/tests/static-export.test.ts b/tests/static-export.test.ts index 73a9722ea..61bd9fb48 100644 --- a/tests/static-export.test.ts +++ b/tests/static-export.test.ts @@ -211,7 +211,6 @@ describe("Static export — App Router (served via HTTP)", () => { await staticExportApp({ baseUrl: viteBaseUrl, routes, - appDir, server: viteServer, outDir: exportDir, config,