diff --git a/packages/vinext/src/build/static-export.ts b/packages/vinext/src/build/static-export.ts index 862fd51b..e2056b92 100644 --- a/packages/vinext/src/build/static-export.ts +++ b/packages/vinext/src/build/static-export.ts @@ -8,19 +8,22 @@ * 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 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 { safeJsonStringify } from "../server/html.js"; import { escapeAttr } from "../shims/head.js"; import path from "node:path"; @@ -29,6 +32,36 @@ 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"); + + const server = await vite.createServer({ + root, + configFile: false, + plugins: [vinextPlugin({ appDir: root })], + 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)); } @@ -120,12 +153,11 @@ export async function staticExportPages( } if (route.isDynamic) { - // Dynamic route — must have getStaticPaths + // Dynamic route — needs getStaticPaths to enumerate params if (typeof pageModule.getStaticPaths !== "function") { - result.errors.push({ - route: route.pattern, - error: `Dynamic route requires getStaticPaths with output: 'export'`, - }); + result.warnings.push( + `Dynamic route ${route.pattern} has no getStaticPaths() — skipping (no pages generated)`, + ); continue; } @@ -204,7 +236,7 @@ export async function staticExportPages( routerShim, }); - 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"); @@ -286,85 +318,86 @@ async function renderStaticPage(options: RenderStaticPageOptions): Promise = {}; + + 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`); + } + } - // Collect page props - let pageProps: Record = {}; + // Build element + const createElement = React.createElement; + let element: React.ReactElement; - if (typeof pageModule.getStaticProps === "function") { - const result = await pageModule.getStaticProps({ params }); - if (result && "props" in result) { - pageProps = result.props as Record; + if (AppComponent) { + element = createElement(AppComponent, { + Component: PageComponent, + pageProps, + }); + } else { + element = createElement(PageComponent, pageProps); } - if (result && "redirect" in result) { - // Static export can't handle redirects — write a meta redirect - const redirect = result.redirect as { destination: string }; - return ``; + + // Reset head collector and flush dynamic preloads + if (typeof headShim.resetSSRHead === "function") { + headShim.resetSSRHead(); } - if (result && "notFound" in result && result.notFound) { - throw new Error(`Page ${urlPath} returned notFound: true`); + if (typeof dynamicShim.flushPreloads === "function") { + await dynamicShim.flushPreloads(); } - } - // Build element - const createElement = React.createElement; - let element: React.ReactElement; + // Render page body + const bodyHtml = await renderToStringAsync(element); - if (AppComponent) { - element = createElement(AppComponent, { - Component: PageComponent, - pageProps, - }); - } else { - element = createElement(PageComponent, pageProps); - } + // Collect head tags + const ssrHeadHTML = + typeof headShim.getSSRHeadHTML === "function" + ? headShim.getSSRHeadHTML() + : ""; - // Reset head collector and flush dynamic preloads - if (typeof headShim.resetSSRHead === "function") { - headShim.resetSSRHead(); - } - if (typeof dynamicShim.flushPreloads === "function") { - await dynamicShim.flushPreloads(); - } + // __NEXT_DATA__ for client hydration + const nextDataScript = ``; - // Render page body - const bodyHtml = await renderToStringAsync(element); - - // Collect head tags - const ssrHeadHTML = - typeof headShim.getSSRHeadHTML === "function" - ? headShim.getSSRHeadHTML() - : ""; - - // __NEXT_DATA__ for client hydration - const nextDataScript = ``; - - // 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 = ` + // 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 = ` @@ -376,14 +409,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 { @@ -502,21 +536,31 @@ function buildUrlFromParams( } /** - * Determine the output file path for a given URL. - * Respects trailingSlash config. + * Determine the output file path for a given URL and verify it stays + * within the output directory. Respects trailingSlash config. + * + * `outDir` is the resolved absolute path of the output directory. + * After computing the relative output path, the function resolves it + * against `outDir` and checks that it doesn't escape the boundary + * (e.g. via crafted `generateStaticParams` / `getStaticPaths` values). */ -function getOutputPath(urlPath: string, trailingSlash: boolean): string { +function getOutputPath(urlPath: string, trailingSlash: boolean, outDir: string): string { if (urlPath === "/") { return "index.html"; } - // Remove leading slash - const clean = urlPath.replace(/^\//, ""); + const normalized = path.posix.normalize(urlPath); + const clean = normalized.replace(/^\//, ""); - if (trailingSlash) { - return `${clean}/index.html`; + const relative = trailingSlash ? `${clean}/index.html` : `${clean}.html`; + + const resolved = path.resolve(outDir, relative); + const resolvedOutDir = path.resolve(outDir); + if (!resolved.startsWith(resolvedOutDir + path.sep)) { + throw new Error(`Output path "${urlPath}" escapes the output directory`); } - return `${clean}.html`; + + return relative; } /** @@ -607,6 +651,42 @@ async function resolveParentParams( return currentParams; } +/** + * Expand a dynamic App Router route into concrete URLs via generateStaticParams. + * Handles parent param resolution (top-down passing). + * Returns the list of expanded URLs, or an empty array if the route has no params. + */ +async function expandDynamicAppRoute( + route: AppRoute, + allRoutes: AppRoute[], + server: ViteDevServer, + generateStaticParams: (opts: { 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 { + paramSets = await generateStaticParams({ params: {} }); + } + } catch (e) { + throw new Error(`generateStaticParams() failed for ${route.pattern}: ${(e as Error).message}`); + } + + if (!Array.isArray(paramSets)) return []; + return paramSets.map((params) => buildUrlFromParams(route.pattern, params)); +} + // ------------------------------------------------------------------- // App Router static export // ------------------------------------------------------------------- @@ -665,46 +745,24 @@ 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'`, - }); + 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.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); - } + urlsToRender.push(...expandedUrls); } catch (e) { result.errors.push({ route: route.pattern, @@ -726,11 +784,12 @@ export async function staticExportApp( route: urlPath, error: `Server returned ${res.status}`, }); + await res.body?.cancel(); // release connection 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"); @@ -763,3 +822,291 @@ export async function staticExportApp( return result; } + +// ------------------------------------------------------------------- +// High-level orchestrator +// ------------------------------------------------------------------- + +export interface RunStaticExportOptions { + root: string; + outDir?: string; + config?: ResolvedNextConfig; + configOverride?: Partial; +} + +/** + * 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) + let config: ResolvedNextConfig; + if (options.config) { + 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: [], + }; + } + + // 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 + 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 routes = await appRouter(appDir); + return await staticExportApp({ + baseUrl, + routes, + appDir, + server, + outDir, + config, + }); + } else { + // Pages Router + 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; +} + +export interface PrerenderResult { + pageCount: number; + files: string[]; + warnings: string[]; + skipped: string[]; +} + +/** + * Pre-render static pages after a production build. + * + * Starts a temporary production server, detects static routes via a temporary + * Vite dev 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"); + + const result: PrerenderResult = { + pageCount: 0, + files: [], + warnings: [], + skipped: [], + }; + + // 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 App Router builds. + if (isAppRouter) { + 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); + + if (collected.urls.length === 0) { + result.warnings.push("No static routes found — nothing to pre-render"); + return result; + } + + const staticUrls = collected.urls; + + // Start temp production server in-process + const { startProdServer } = await import("../server/prod-server.js"); + const server = await startProdServer({ + port: 0, // Random available port + host: "127.0.0.1", + outDir: distDir, + }); + const addr = server.address() as import("node:net").AddressInfo; + const port = addr.port; + + 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); + await res.text(); // consume body + continue; + } + + const html = await res.text(); + const outputPath = getOutputPath(urlPath, false, 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++; + } catch { + result.skipped.push(urlPath); + } 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). + */ +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: [] }; + + const routes = await pagesRouter(pagesDir); + const urls: string[] = []; + const skipped: string[] = []; + + // Patterns that indicate a page has server-side data fetching + const gsspPattern = /export\s+(async\s+)?function\s+getServerSideProps|export\s+(const|let|var)\s+getServerSideProps/; + const revalidateZeroPattern = /export\s+const\s+revalidate\s*=\s*0\b/; + + 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)`); + continue; + } + + try { + const source = fs.readFileSync(route.filePath, "utf-8"); + + if (gsspPattern.test(source)) { + skipped.push(`${route.pattern} (getServerSideProps)`); + continue; + } + + if (revalidateZeroPattern.test(source)) { + skipped.push(`${route.pattern} (revalidate: 0)`); + continue; + } + + urls.push(route.pattern); + } catch { + skipped.push(`${route.pattern} (failed to read source)`); + } + } + + return { urls, skipped }; +} + +interface CollectedRoutes { + urls: string[]; + skipped: string[]; +} + diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 0b4f253e..62ef336c 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -270,6 +270,60 @@ async function buildApp() { })); } + // ── Static export (output: "export") ────────────────────────── + const { loadNextConfig, resolveNextConfig } = await import( + /* @vite-ignore */ "./config/next-config.js" + ); + const rawConfig = await loadNextConfig(process.cwd()); + const resolvedConfig = await resolveNextConfig(rawConfig); + + 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); + } + + 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) ──────────────── + console.log(" Pre-rendering static pages...\n"); + + const { prerenderStaticPages } = await import( + /* @vite-ignore */ "./build/static-export.js" + ); + + const prerenderResult = await prerenderStaticPages({ root: process.cwd() }); + + if (prerenderResult.warnings.length > 0) { + for (const w of prerenderResult.warnings) console.log(` Warning: ${w}`); + } + if (prerenderResult.skipped.length > 0) { + console.log(` Skipped ${prerenderResult.skipped.length} route(s) (dynamic or errored)`); + } + + if (prerenderResult.pageCount > 0) { + console.log(`\n Pre-rendered ${prerenderResult.pageCount} static page(s)\n`); + } + console.log("\n Build complete. Run `vinext start` to start the production server.\n"); } @@ -282,6 +336,21 @@ async function start() { mode: "production", }); + // Reject static export builds — they don't need a production server + 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.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/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 33447856..20ab258b 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -280,6 +280,34 @@ 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" + const normalized = pathname === "/" ? "index" : pathname.replace(/^\//, "").replace(/\/$/, ""); + + // Guard against directory traversal + const resolvedDir = path.resolve(dir); + + const directPath = path.join(dir, `${normalized}.html`); + if (path.resolve(directPath).startsWith(resolvedDir + path.sep) && fs.existsSync(directPath) && fs.statSync(directPath).isFile()) { + return directPath; + } + + 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). @@ -679,6 +707,11 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { contentSecurityPolicy: vinextConfig.images.contentSecurityPolicy, } : 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); + const server = createServer(async (req, res) => { const rawUrl = req.url ?? "/"; // Normalize backslashes (browsers treat /\ as //), then decode and normalize path. @@ -945,6 +978,19 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { } } + // ── 7b. Pre-rendered HTML ───────────────────────────────────── + // Serve build-time rendered static pages. Placed after middleware, + // basePath stripping, redirects, and rewrites so those all run first. + const pagesPrerenderedFile = hasPrerenderedPages + ? resolvePrerenderedHtml(pagesPrerenderedDir, resolvedPathname) + : null; + if (pagesPrerenderedFile) { + const html = await fs.promises.readFile(pagesPrerenderedFile, "utf-8"); + const prerenderedHeaders: Record = { ...middlewareHeaders }; + sendCompressed(req, res, html, "text/html; charset=utf-8", 200, prerenderedHeaders, compress); + return; + } + // ── 8. API routes ───────────────────────────────────────────── if (resolvedPathname.startsWith("/api/") || resolvedPathname === "/api") { let response: Response; diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index bc9513ca..8c370f50 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1672,7 +1672,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" ); @@ -1714,9 +1714,10 @@ describe("App Router Static export", () => { config, }); - // Should have an error about missing generateStaticParams + // Should warn (not error) about missing generateStaticParams + expect(result.errors).toHaveLength(0); expect( - result.errors.some((e) => e.error.includes("generateStaticParams")), + result.warnings.some((w) => w.includes("generateStaticParams")), ).toBe(true); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); diff --git a/tests/build-prerender.test.ts b/tests/build-prerender.test.ts new file mode 100644 index 00000000..45c8f42c --- /dev/null +++ b/tests/build-prerender.test.ts @@ -0,0 +1,164 @@ +/** + * 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 ───────────────────────────── + +describe("Production server — serves pre-rendered HTML", () => { + const outDir = path.resolve(PAGES_FIXTURE, "dist"); + const serverEntryPath = path.join(outDir, "server", "entry.js"); + const pagesDir = path.join(outDir, "server", "pages"); + const prerenderedFile = path.join(pagesDir, "prerendered-test.html"); + let server: Server; + let baseUrl: string; + + beforeAll(async () => { + if (!fs.existsSync(serverEntryPath)) { + throw new Error( + `Fixture not built: ${serverEntryPath} does not exist. ` + + `Run "cd ${PAGES_FIXTURE} && pnpm build" first.`, + ); + } + + // 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", + ); + + 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}`; + }); + + afterAll(async () => { + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } + // Clean up the fake pre-rendered file and pages directory + if (fs.existsSync(prerenderedFile)) { + fs.rmSync(prerenderedFile); + } + if (fs.existsSync(pagesDir) && fs.readdirSync(pagesDir).length === 0) { + fs.rmdirSync(pagesDir); + } + }); + + 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( + "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 + 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 () => { + 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); + } + }, + ); +}); + +// ─── 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("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"); + }); +}); diff --git a/tests/build-static-export.test.ts b/tests/build-static-export.test.ts new file mode 100644 index 00000000..b934f4e5 --- /dev/null +++ b/tests/build-static-export.test.ts @@ -0,0 +1,153 @@ +/** + * 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 APP_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/app-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, + configOverride: { output: "export" }, + }); + }, 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 ────────────────────────────────────────────────────────────── + +describe("runStaticExport — App Router", () => { + let result: StaticExportResult; + const outDir = path.resolve(APP_FIXTURE, "out-run-static-app"); + + beforeAll(async () => { + result = await runStaticExport({ + root: APP_FIXTURE, + outDir, + configOverride: { output: "export" }, + }); + }, 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("expands dynamic routes via generateStaticParams", () => { + // app-basic/app/blog/[slug]/page.tsx defines hello-world, getting-started, advanced-guide + expect(result.files).toContain("blog/hello-world.html"); + expect(result.files).toContain("blog/getting-started.html"); + expect(result.files).toContain("blog/advanced-guide.html"); + }); + + it("generates 404.html", () => { + expect(result.files).toContain("404.html"); + expect(fs.existsSync(path.join(outDir, "404.html"))).toBe(true); + }); + + it("produces a warning (not error) for empty generateStaticParams", () => { + // If a dynamic route's generateStaticParams returns [], it should be a + // warning — the route is simply skipped — not a hard error. + // This is tested structurally: warnings are strings, errors have { route, error }. + // The existing staticExportApp already handles this as a warning. + for (const w of result.warnings) { + expect(typeof w).toBe("string"); + } + for (const e of result.errors) { + expect(e).toHaveProperty("route"); + expect(e).toHaveProperty("error"); + // No error should mention "empty" generateStaticParams — that goes in warnings + expect(e.error).not.toMatch(/returned empty array/); + } + }); + + 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([]); + }); +}); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 8223b891..27e9c6c9 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -1727,6 +1727,46 @@ 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"),