diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts index 8cbdc0316..12e4496f8 100644 --- a/packages/vinext/src/build/prerender.ts +++ b/packages/vinext/src/build/prerender.ts @@ -44,6 +44,7 @@ import { VINEXT_PRERENDER_CACHE_LIFE_HEADER, VINEXT_PRERENDER_METADATA_ROUTES_PATH, VINEXT_PRERENDER_RENDER_ERROR_HEADER, + VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER, VINEXT_PRERENDER_ROUTE_PARAMS_HEADER, VINEXT_PRERENDER_SECRET_HEADER, VINEXT_PRERENDER_SPECULATIVE_HEADER, @@ -209,6 +210,28 @@ type PrerenderProgressCallback = (update: { status: PrerenderRouteResult["status"]; }) => void; +function nonCacheablePagesResult( + mode: PrerenderOptions["mode"], + route: string, + path = route, +): PrerenderRouteResult { + if (mode === "export") { + return { + route, + status: "error", + error: + "Page returned an explicitly non-cacheable response which is not supported with output: 'export'", + }; + } + + return { + route, + status: "skipped", + reason: "dynamic", + ...(path !== route ? { path } : {}), + }; +} + type PrerenderOptions = { /** * 'default' — prerender static/ISR routes; skip SSR routes @@ -950,40 +973,57 @@ export async function prerenderPages({ ); const htmlFullPath = path.join(outDir, htmlOutputPath); - if (response.status >= 300 && response.status < 400) { - // getStaticProps returned a redirect — emit a meta-refresh HTML page - // so the static export can represent the redirect without a server. - const dest = response.headers.get("location") ?? "/"; - const escapedDest = dest - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(//g, ">"); - const html = ``; - fs.mkdirSync(path.dirname(htmlFullPath), { recursive: true }); - fs.writeFileSync(htmlFullPath, html, "utf-8"); - outputFiles.push(htmlOutputPath); + const isRedirectResponse = response.status >= 300 && response.status < 400; + const isDynamicResponse = + (response.ok || isRedirectResponse) && + response.headers.get(VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER) === "1"; + if (isDynamicResponse) { + await response.body?.cancel(); + result = nonCacheablePagesResult(mode, route.pattern, urlPath); + } else if (!isRedirectResponse && !response.ok) { + const fatal = response.headers.get(VINEXT_PRERENDER_RENDER_ERROR_HEADER) === "1"; + await response.body?.cancel(); + const renderError = new Error(`renderPage returned ${response.status} for ${urlPath}`); + result = { + route: route.pattern, + status: "error", + error: config.enablePrerenderSourceMaps + ? getErrorMessageWithStack(renderError) + : renderError.message, + ...(fatal ? { fatal: true as const } : {}), + }; } else { - if (!response.ok) { - throw new Error(`renderPage returned ${response.status} for ${urlPath}`); + if (isRedirectResponse) { + // getStaticProps returned a redirect — emit a meta-refresh HTML page + // so the static export can represent the redirect without a server. + const dest = response.headers.get("location") ?? "/"; + const escapedDest = dest + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); + const html = ``; + fs.mkdirSync(path.dirname(htmlFullPath), { recursive: true }); + fs.writeFileSync(htmlFullPath, html, "utf-8"); + outputFiles.push(htmlOutputPath); + } else { + const html = await response.text(); + fs.mkdirSync(path.dirname(htmlFullPath), { recursive: true }); + fs.writeFileSync(htmlFullPath, html, "utf-8"); + outputFiles.push(htmlOutputPath); } - const html = await response.text(); - fs.mkdirSync(path.dirname(htmlFullPath), { recursive: true }); - fs.writeFileSync(htmlFullPath, html, "utf-8"); - outputFiles.push(htmlOutputPath); + result = { + route: route.pattern, + status: "rendered", + outputFiles, + revalidate, + // Pages Router cache metadata comes only from getStaticProps.revalidate; + // Next.js applies expireTime as the fallback when no route expire exists. + ...(typeof revalidate === "number" ? { expire: config.expireTime } : {}), + router: "pages", + ...(urlPath !== route.pattern ? { path: urlPath } : {}), + }; } - - result = { - route: route.pattern, - status: "rendered", - outputFiles, - revalidate, - // Pages Router cache metadata comes only from getStaticProps.revalidate; - // Next.js applies expireTime as the fallback when no route expire exists. - ...(typeof revalidate === "number" ? { expire: config.expireTime } : {}), - router: "pages", - ...(urlPath !== route.pattern ? { path: urlPath } : {}), - }; } catch (e) { renderPool?.recordRenderError(e); const err = e as Error; @@ -1016,15 +1056,32 @@ export async function prerenderPages({ try { const notFoundRes = await renderPage(hasCustom404 ? "/404" : NOT_FOUND_SENTINEL_PATH); const contentType = notFoundRes.headers.get("content-type") ?? ""; - if (notFoundRes.status === 404 && contentType.includes("text/html")) { - const html404 = await notFoundRes.text(); + if (!notFoundRes.ok && notFoundRes.status !== 404) { + const fatal = notFoundRes.headers.get(VINEXT_PRERENDER_RENDER_ERROR_HEADER) === "1"; + await notFoundRes.body?.cancel(); + const renderError = new Error(`renderPage returned ${notFoundRes.status} for /404`); results.push({ route: "/404", - status: "rendered", - outputFiles: emitStatic404Files(outDir, html404, config.trailingSlash), - revalidate: false, - router: "pages", + status: "error", + error: config.enablePrerenderSourceMaps + ? getErrorMessageWithStack(renderError) + : renderError.message, + ...(fatal ? { fatal: true as const } : {}), }); + } else if (notFoundRes.status === 404 && contentType.includes("text/html")) { + if (notFoundRes.headers.get(VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER) === "1") { + await notFoundRes.body?.cancel(); + results.push(nonCacheablePagesResult(mode, "/404")); + } else { + const html404 = await notFoundRes.text(); + results.push({ + route: "/404", + status: "rendered", + outputFiles: emitStatic404Files(outDir, html404, config.trailingSlash), + revalidate: false, + router: "pages", + }); + } } } catch (e) { // No custom 404. When the render-worker pool is active, a transport diff --git a/packages/vinext/src/server/app-prerender-endpoints.ts b/packages/vinext/src/server/app-prerender-endpoints.ts index 096460967..e83596163 100644 --- a/packages/vinext/src/server/app-prerender-endpoints.ts +++ b/packages/vinext/src/server/app-prerender-endpoints.ts @@ -5,6 +5,10 @@ import { VINEXT_PRERENDER_STATIC_PARAMS_PATH, } from "./headers.js"; import { notFoundResponse } from "./http-error-responses.js"; +import { + assertPagesDataExportCompatibility, + type PagesDataExportModule, +} from "./pages-data-export-compatibility.js"; import type { RootParams } from "vinext/shims/root-params"; type GenerateStaticParams = (args: { params: RootParams }) => unknown; @@ -14,7 +18,7 @@ export type AppPrerenderRootParamNamesMap = Record unknown; }; }; @@ -116,6 +120,9 @@ async function handlePagesStaticPathsEndpoint( try { const pageRoutes = await options.loadPagesRoutes?.(); const route = findPageRoute(pageRoutes, pattern); + if (route?.module) { + assertPagesDataExportCompatibility(route.module, pattern); + } const getStaticPaths = route?.module?.getStaticPaths; if (typeof getStaticPaths !== "function") { return jsonNullResponse(); diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index e72c116f0..8f0a33446 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -61,6 +61,7 @@ import { type PagesRedirectResult, type PagesStaticPathsEntry, } from "./pages-page-data.js"; +import { assertPagesDataExportCompatibility } from "./pages-data-export-compatibility.js"; import { sanitizeDestination } from "../config/config-matchers.js"; import { collectPagesDevInitialStylesheetHeadHTML } from "./pages-dev-stylesheets.js"; import { createPagesDevModuleUrl } from "./pages-dev-module-url.js"; @@ -937,6 +938,10 @@ export function createSSRHandler( // and `useRouter().isFallback === true`, matching Next.js render.tsx. let isFallbackRender = false; + if (typeof pageModule.getStaticProps === "function") { + assertPagesDataExportCompatibility(pageModule, patternToNextFormat(route.pattern)); + } + // Handle getStaticPaths for dynamic routes: validate the path, // respect `fallback: false` (return 404 for unlisted paths), and // render the loading shell for unlisted paths under `fallback: true`. @@ -1215,6 +1220,7 @@ export function createSSRHandler( const scriptNonce = getScriptNonceFromNodeHeaderSources(req.headers, responseHeaders); if (typeof pageModule.getStaticProps === "function" && !isFallbackRender) { + const routePattern = patternToNextFormat(route.pattern); // An authenticated res.revalidate() request executes GSP once with the // on-demand reason, but Pages response entries are never read or // written in development. Ordinary requests independently rerun GSP @@ -1258,7 +1264,6 @@ export function createSSRHandler( } const result = await pageModule.getStaticProps(context); - const routePattern = patternToNextFormat(route.pattern); assertPages404DoesNotReturnNotFound(routePattern, result); if (result) { staticPropsRevalidateSeconds = resolvePagesRevalidateSeconds(result, routePattern); diff --git a/packages/vinext/src/server/headers.ts b/packages/vinext/src/server/headers.ts index abd7e8677..1d8ba4cf9 100644 --- a/packages/vinext/src/server/headers.ts +++ b/packages/vinext/src/server/headers.ts @@ -99,6 +99,9 @@ export const VINEXT_PRERENDER_CACHE_LIFE_HEADER = "x-vinext-prerender-cache-life /** Marks a local prerender-server 500 that originated from a thrown render error. */ export const VINEXT_PRERENDER_RENDER_ERROR_HEADER = "x-vinext-prerender-render-error"; +/** Marks a Pages prerender response whose request-derived App props prohibit reuse. */ +export const VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER = "x-vinext-prerender-shared-cache-bypass"; + /** Internal marker persisted only inside metadata-route APP_ROUTE cache values. */ export const VINEXT_METADATA_ROUTE_CACHE_HEADER = "x-vinext-metadata-route-cache"; diff --git a/packages/vinext/src/server/pages-data-export-compatibility.ts b/packages/vinext/src/server/pages-data-export-compatibility.ts new file mode 100644 index 000000000..e71f818eb --- /dev/null +++ b/packages/vinext/src/server/pages-data-export-compatibility.ts @@ -0,0 +1,35 @@ +import { hasPagesGetInitialProps } from "./pages-get-initial-props.js"; +import { VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER } from "./headers.js"; + +const SSG_GET_INITIAL_PROPS_CONFLICT = + "You can not use getInitialProps with getStaticProps. To use SSG, please remove your getInitialProps"; + +export type PagesDataExportModule = { + default?: unknown; + getStaticProps?: unknown; +}; + +export class PagesDataExportCompatibilityError extends Error { + override name = "PagesDataExportCompatibilityError"; +} + +export function markPagesPrerenderSharedCacheBypass(headers: Headers): void { + if (process.env.VINEXT_PRERENDER === "1") { + headers.set(VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER, "1"); + } +} + +/** Reject Pages data-export combinations that Next.js does not allow. */ +export function assertPagesDataExportCompatibility( + pageModule: PagesDataExportModule, + routePattern: string, +): void { + if ( + typeof pageModule.getStaticProps === "function" && + hasPagesGetInitialProps(pageModule.default) + ) { + throw new PagesDataExportCompatibilityError( + `${SSG_GET_INITIAL_PROPS_CONFLICT} ${routePattern}`, + ); + } +} diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts index 667538ec4..faf065e07 100644 --- a/packages/vinext/src/server/pages-page-data.ts +++ b/packages/vinext/src/server/pages-page-data.ts @@ -10,7 +10,7 @@ import type { CacheControlMetadata, } from "vinext/shims/cache-handler"; import { applyCdnResponseHeaders } from "./cache-control.js"; -import { buildMissIsrCacheControl, decideIsr } from "./isr-decision.js"; +import { buildMissIsrCacheControl, decideIsr, ISR_NEVER_CACHE_CONTROL } from "./isr-decision.js"; import { buildCacheStateHeaders } from "./cache-headers.js"; import { buildPagesCacheValue, @@ -43,6 +43,7 @@ import { isBotUserAgent } from "../utils/html-limited-bots.js"; import { isUnknownRecord } from "../utils/record.js"; import { isDangerousScheme } from "vinext/shims/url-safety"; import { encodeCacheTag } from "../utils/encode-cache-tag.js"; +import { assertPagesDataExportCompatibility } from "./pages-data-export-compatibility.js"; export type PagesRedirectResult = { destination: string; @@ -58,7 +59,6 @@ export type ResolvedPagesRedirect = { }; const ALLOWED_PAGES_REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]); - /** Headers that are part of a cached Pages representation, never request state. */ function isCachedPagesRepresentationHeader(name: string): boolean { const lowerName = name.toLowerCase(); @@ -382,6 +382,11 @@ type ResolvePagesPageDataRenderResult = { gsspRes: PagesGsspResponse | null; isrRevalidateSeconds: number | false | null; isrExpireSeconds?: number; + /** + * True when a getStaticProps render carries request-derived App props. The + * caller must emit a private, no-store policy so no shared cache stores it. + */ + bypassSharedCache: boolean; pageProps: Record; props: PagesRenderProps; /** @@ -402,6 +407,8 @@ type ResolvePagesPageDataResponseResult = { type ResolvePagesPageDataNotFoundResult = { kind: "notFound"; + /** The source getStaticProps route carried request-derived App props. */ + bypassSharedCache?: boolean; /** Headers set by getServerSideProps before it returned notFound. */ responseHeaders?: Record; /** Current getStaticProps cache lifetime, when this is an SSG result. */ @@ -456,6 +463,19 @@ function buildPagesNotFoundResult( }; } +function buildRequestAwarePagesNotFoundResult( + options: Pick, +): ResolvePagesPageDataResponseResult | ResolvePagesPageDataNotFoundResult { + const result = buildPagesNotFoundResult(options); + if (result.kind === "response") { + applyCdnResponseHeaders(result.response.headers, { + cacheControl: ISR_NEVER_CACHE_CONTROL, + }); + return result; + } + return { ...result, bypassSharedCache: true }; +} + export function mergePagesNotFoundSourceHeaders( response: Response, sourceHeaders: Record | undefined, @@ -482,6 +502,16 @@ export function mergePagesNotFoundSourceHeaders( }); } +/** + * True only when userland overrode App.getInitialProps. The shim's inherited + * default (`class MyApp extends App {}`) is request-agnostic and keeps ISR. + */ +export function hasCustomAppGetInitialProps(appComponent: unknown): boolean { + if (!hasPagesGetInitialProps(appComponent)) return false; + const component = appComponent as { getInitialProps?: unknown; origGetInitialProps?: unknown }; + return component.getInitialProps !== component.origGetInitialProps; +} + function applyPagesTerminalMissHeaders( response: Response, revalidateSeconds: number | false, @@ -1190,6 +1220,10 @@ export async function renderPagesIsrHtml(options: RenderPagesIsrHtmlOptions): Pr export async function resolvePagesPageData( options: ResolvePagesPageDataOptions, ): Promise { + // Next.js rejects this combination before it can mix request-derived page + // props with a shared static representation. + assertPagesDataExportCompatibility(options.pageModule, options.routePattern); + // Next.js passes `params: null` (effectively) to gSSP/gSP context for // non-dynamic routes — see render.tsx's `...(pageIsDynamic ? { params } : undefined)`. // Internal bookkeeping (route param hydration, ISR HTML, getStaticPaths @@ -1210,6 +1244,10 @@ export async function resolvePagesPageData( let shouldPersistFallbackData = false; let onDemandPreviousCacheEntry: ISRCacheEntry | null | undefined; const previewData = options.isOnDemandRevalidate ? false : (options.previewData ?? false); + // A user-defined App.getInitialProps receives the live request. Its result + // can contain cookies, headers, or other per-user data and must never enter + // (or be read from) the shared ISR cache used by getStaticProps pages. + const hasRequestAwareAppProps = hasCustomAppGetInitialProps(options.AppComponent); if (typeof options.pageModule.getStaticPaths === "function" && options.route.isDynamic) { const pathsResult = await options.pageModule.getStaticPaths({ @@ -1234,7 +1272,9 @@ export async function resolvePagesPageData( // For data requests (`/_next/data/...json`), return a JSON-shaped 404 // so the client router can `res.json()` without blowing up — matches // Next.js' behavior. HTML navigations still get the configured 404 page. - return buildPagesNotFoundResult(options); + return hasRequestAwareAppProps + ? buildRequestAwarePagesNotFoundResult(options) + : buildPagesNotFoundResult(options); } // Render the fallback shell for unlisted paths under `fallback: true`. @@ -1260,7 +1300,11 @@ export async function resolvePagesPageData( options.revalidateOnlyGenerated ) { const pathname = options.isrCachePathname ?? options.routeUrl.split("?")[0]; - onDemandPreviousCacheEntry = await options.isrGet(options.isrCacheKey("pages", pathname)); + // Request-aware App props never produce a shared entry, so there is + // nothing generated to revalidate. + onDemandPreviousCacheEntry = hasRequestAwareAppProps + ? null + : await options.isrGet(options.isrCacheKey("pages", pathname)); if (!onDemandPreviousCacheEntry) { return { kind: "response", @@ -1295,9 +1339,13 @@ export async function resolvePagesPageData( async function loadForegroundAppInitialRenderProps(): Promise { const result = await loadPagesAppInitialRenderProps(options, getSharedReqRes); if (result.kind === "response") { + const response = await result.response; + if (hasRequestAwareAppProps && typeof options.pageModule.getStaticProps === "function") { + applyCdnResponseHeaders(response.headers, { cacheControl: ISR_NEVER_CACHE_CONTROL }); + } return { kind: "response", - response: await result.response, + response, }; } renderProps = result.renderProps; @@ -1307,7 +1355,9 @@ export async function resolvePagesPageData( if (isFallback) { const pathname = options.isrCachePathname ?? options.routeUrl.split("?")[0]; - const cached = await options.isrGet(options.isrCacheKey("pages", pathname)); + const cached = hasRequestAwareAppProps + ? null + : await options.isrGet(options.isrCacheKey("pages", pathname)); if (cached?.value.value?.kind !== "PAGES") { const appShortCircuit = await loadForegroundAppInitialRenderProps(); if (appShortCircuit) return appShortCircuit; @@ -1318,6 +1368,7 @@ export async function resolvePagesPageData( documentReqRes: sharedReqRes, gsspRes: null, isrRevalidateSeconds: null, + bypassSharedCache: hasRequestAwareAppProps, pageProps, props: renderProps, isFallback: true, @@ -1405,8 +1456,9 @@ export async function resolvePagesPageData( if (typeof options.pageModule.getStaticProps === "function") { const pathname = options.isrCachePathname ?? options.routeUrl.split("?")[0]; const cacheKey = options.isrCacheKey("pages", pathname); - const cached = - onDemandPreviousCacheEntry !== undefined + const cached = hasRequestAwareAppProps + ? null + : onDemandPreviousCacheEntry !== undefined ? onDemandPreviousCacheEntry : await options.isrGet(cacheKey); const cachedValue = cached?.value.value; @@ -1722,9 +1774,12 @@ export async function resolvePagesPageData( if (result?.redirect) { const response = buildPagesRedirectResponse(result.redirect, options, renderProps); - if (previewData === false) { - const revalidateSeconds = resolvePagesRevalidateSeconds(result, options.routeUrl); - const expireSeconds = resolvePagesExpireSeconds(result, options.expireSeconds); + // Validate `revalidate` even when the result never enters the cache. + const revalidateSeconds = resolvePagesRevalidateSeconds(result, options.routeUrl); + const expireSeconds = resolvePagesExpireSeconds(result, options.expireSeconds); + if (hasRequestAwareAppProps) { + applyCdnResponseHeaders(response.headers, { cacheControl: ISR_NEVER_CACHE_CONTROL }); + } else if (previewData === false) { const redirect = resolvePagesRedirect(result.redirect, { method: "getStaticProps", routeUrl: options.routeUrl, @@ -1749,6 +1804,11 @@ export async function resolvePagesPageData( if (result?.notFound) { const revalidateSeconds = resolvePagesRevalidateSeconds(result, options.routeUrl); const expireSeconds = resolvePagesExpireSeconds(result, options.expireSeconds); + // The recursive custom 404 render also runs App.getInitialProps, so + // request-aware App props must not receive a shared cache lifetime either. + if (hasRequestAwareAppProps) { + return buildRequestAwarePagesNotFoundResult(options); + } if (previewData === false) { await options.isrSet(cacheKey, null, { cacheControl: isrCacheControl(revalidateSeconds, { expireSeconds }), @@ -1783,10 +1843,13 @@ export async function resolvePagesPageData( isSerializableProps(options.routePattern, "getStaticProps", pageProps); } - if (previewData === false && result) { + if (previewData === false && result && !hasRequestAwareAppProps) { isrRevalidateSeconds = resolvePagesRevalidateSeconds(result, options.routeUrl); isrExpireSeconds = resolvePagesExpireSeconds(result, options.expireSeconds); - } else if (previewData === false && options.isOnDemandRevalidate) { + } else if (result && hasRequestAwareAppProps) { + // Still validate `revalidate`; only cache participation is skipped. + resolvePagesRevalidateSeconds(result, options.routeUrl); + } else if (previewData === false && options.isOnDemandRevalidate && !hasRequestAwareAppProps) { // `revalidate: false` (and an omitted `revalidate`) still participates in // on-demand regeneration. Persist the current invocation's normalized // lifetime instead of inheriting stale metadata from the previous entry. @@ -1800,7 +1863,7 @@ export async function resolvePagesPageData( isrExpireSeconds = cached?.value.cacheControl?.expire; } - if (shouldPersistFallbackData && previewData === false) { + if (shouldPersistFallbackData && previewData === false && !hasRequestAwareAppProps) { const revalidateSeconds = isrRevalidateSeconds ?? false; await options.isrSet( cacheKey, @@ -1866,6 +1929,8 @@ export async function resolvePagesPageData( gsspRes, isrRevalidateSeconds, isrExpireSeconds, + bypassSharedCache: + hasRequestAwareAppProps && typeof options.pageModule.getStaticProps === "function", pageProps, props: renderProps, isFallback: false, diff --git a/packages/vinext/src/server/pages-page-handler.ts b/packages/vinext/src/server/pages-page-handler.ts index e68ebacdb..59b892d07 100644 --- a/packages/vinext/src/server/pages-page-handler.ts +++ b/packages/vinext/src/server/pages-page-handler.ts @@ -24,7 +24,15 @@ import { type PagesPreviewState, } from "./pages-preview.js"; import { hasUserDocumentGetInitialProps } from "./document-initial-head.js"; -import { mergePagesNotFoundSourceHeaders, resolvePagesPageData } from "./pages-page-data.js"; +import { + hasCustomAppGetInitialProps, + mergePagesNotFoundSourceHeaders, + resolvePagesPageData, +} from "./pages-page-data.js"; +import { + markPagesPrerenderSharedCacheBypass, + PagesDataExportCompatibilityError, +} from "./pages-data-export-compatibility.js"; import type { PagesPageModule } from "./pages-page-data.js"; import { resolvePagesPageMethodResponse } from "./pages-page-method.js"; import { renderPagesPageResponse } from "./pages-page-response.js"; @@ -77,6 +85,7 @@ import { NEXTJS_CACHE_HEADER, NEXTJS_DEPLOYMENT_ID_HEADER, VINEXT_CACHE_HEADER, + VINEXT_PRERENDER_RENDER_ERROR_HEADER, VINEXT_REVALIDATED_CACHE_TAG_HEADER, } from "./headers.js"; import { buildMissIsrCacheControl, ISR_NEVER_CACHE_CONTROL } from "./isr-decision.js"; @@ -346,6 +355,8 @@ type RenderPageOptions = { originalUrl?: string; renderErrorPageOnMiss?: boolean; __isInternalErrorRender?: boolean; + /** Force adapter-backed no-store while recursively rendering an error page. */ + __bypassSharedCache?: boolean; __forcedRoute?: PageRoute; /** Source-page cache lifetime forwarded while rendering a notFound error page. */ __notFoundRevalidateSeconds?: number | false; @@ -592,6 +603,8 @@ export function createPagesPageHandler( const { route, params } = match; const pageModule = route.module; const isStaticPropsRoute = typeof pageModule.getStaticProps === "function"; + const requestAwareStaticPropsRoute = + isStaticPropsRoute && hasCustomAppGetInitialProps(AppComponent); const pagesReadiness = buildPagesReadinessNextData({ pageModule, appComponent: AppComponent as { getInitialProps?: unknown; origGetInitialProps?: unknown }, @@ -980,6 +993,12 @@ export function createPagesPageHandler( ); } notFoundResponse = stripPagesNotFoundFramingHeaders(notFoundResponse); + if (pageDataResult.bypassSharedCache) { + applyCdnResponseHeaders(notFoundResponse.headers, { + cacheControl: ISR_NEVER_CACHE_CONTROL, + }); + markPagesPrerenderSharedCacheBypass(notFoundResponse.headers); + } if (isOnDemandRevalidate) { notFoundResponse = withPagesCacheState( @@ -1005,6 +1024,10 @@ export function createPagesPageHandler( errorResponseCachePathname, ); } + if (options?.__bypassSharedCache) { + applyCdnResponseHeaders(response.headers, { cacheControl: ISR_NEVER_CACHE_CONTROL }); + markPagesPrerenderSharedCacheBypass(response.headers); + } return finalizePagesPreviewResponse(response, preview); } @@ -1029,6 +1052,8 @@ export function createPagesPageHandler( // response and must not shorten `/404`'s internal cache lifetime. const isrRevalidateSeconds = pageDataResult.isrRevalidateSeconds; const isrExpireSeconds = pageDataResult.isrExpireSeconds; + const bypassSharedCache = + pageDataResult.bypassSharedCache || options?.__bypassSharedCache === true; const isFallbackRender = pageDataResult.isFallback === true; // Republish SSR context with isFallback flipped on so `useRouter().isFallback` @@ -1068,6 +1093,8 @@ export function createPagesPageHandler( // skip when gSSP already set one via res.setHeader. Fixes #1461. if (!headers.has("Cache-Control")) headers.set("Cache-Control", ISR_NEVER_CACHE_CONTROL); + } else if (bypassSharedCache) { + applyCdnResponseHeaders(headers, { cacheControl: ISR_NEVER_CACHE_CONTROL }); } else if (isStaticPropsRoute) { if (isrRevalidateSeconds !== null) { const stem = isrCachePathname.endsWith("/") @@ -1151,6 +1178,7 @@ export function createPagesPageHandler( isrCachePathname, expireSeconds: isrExpireSeconds, isrRevalidateSeconds, + bypassSharedCache, isOnDemandRevalidate, isStaticPropsRoute, isrSet: routeIsrSet, @@ -1187,9 +1215,17 @@ export function createPagesPageHandler( errorResponseCachePathname, ); } + if (options?.__bypassSharedCache) { + applyCdnResponseHeaders(pageResponse.headers, { cacheControl: ISR_NEVER_CACHE_CONTROL }); + markPagesPrerenderSharedCacheBypass(pageResponse.headers); + } return finalizePagesPreviewResponse(pageResponse, preview); } catch (e) { console.error("[vinext] SSR error:", e); + const isFatalPrerenderError = + e instanceof PagesDataExportCompatibilityError && + typeof process !== "undefined" && + process.env?.VINEXT_PRERENDER === "1"; reportRequestError( e instanceof Error ? e : new Error(String(e)), { @@ -1222,7 +1258,7 @@ export function createPagesPageHandler( } if (errorRoute) { try { - return await renderPage( + const errorResponse = await renderPage( request, url, manifest, @@ -1232,17 +1268,31 @@ export function createPagesPageHandler( asPath: url, renderErrorPageOnMiss: false, __isInternalErrorRender: true, + __bypassSharedCache: + requestAwareStaticPropsRoute || options?.__bypassSharedCache === true, __forcedRoute: errorRoute, err: e instanceof Error ? e : new Error(String(e)), }, initialResponseHeaders, ); + if (isFatalPrerenderError) { + errorResponse.headers.set(VINEXT_PRERENDER_RENDER_ERROR_HEADER, "1"); + } + return errorResponse; } catch (errorPageErr) { console.error("[vinext] Error page render failed:", errorPageErr); } } } - return new Response("Internal Server Error", { status: 500 }); + const response = new Response("Internal Server Error", { status: 500 }); + if (requestAwareStaticPropsRoute || options?.__bypassSharedCache) { + applyCdnResponseHeaders(response.headers, { cacheControl: ISR_NEVER_CACHE_CONTROL }); + markPagesPrerenderSharedCacheBypass(response.headers); + } + if (isFatalPrerenderError) { + response.headers.set(VINEXT_PRERENDER_RENDER_ERROR_HEADER, "1"); + } + return response; } }); return closeAfterResponseWithBody(response, uCtx); diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts index ec7f530b4..a521974cd 100644 --- a/packages/vinext/src/server/pages-page-response.ts +++ b/packages/vinext/src/server/pages-page-response.ts @@ -24,6 +24,7 @@ import { type RenderPageEnhancers, runDocumentRenderPage, } from "./pages-document-initial-props.js"; +import { markPagesPrerenderSharedCacheBypass } from "./pages-data-export-compatibility.js"; import { fnv1a52 } from "../utils/hash.js"; import { readStreamAsText } from "../utils/text-stream.js"; import { callDocumentGetInitialProps } from "./document-initial-head.js"; @@ -159,6 +160,8 @@ type RenderPagesPageResponseOptions = { isrCachePathname?: string; expireSeconds?: number; isrRevalidateSeconds: number | false | null; + /** Request-derived App props: emit a private, no-store policy instead of ISR headers. */ + bypassSharedCache?: boolean; /** Synchronous `res.revalidate()` render; cache persistence must finish before returning. */ isOnDemandRevalidate?: boolean; isStaticPropsRoute?: boolean; @@ -561,7 +564,12 @@ export async function renderPagesPageResponse( }, }); if (options.documentReqRes?.res.headersSent && options.documentReqRes.responsePromise) { - return options.documentReqRes.responsePromise; + const response = await options.documentReqRes.responsePromise; + if (options.bypassSharedCache) { + applyCdnResponseHeaders(response.headers, { cacheControl: ISR_NEVER_CACHE_CONTROL }); + markPagesPrerenderSharedCacheBypass(response.headers); + } + return response; } let bodyStream: ReadableStream; @@ -697,7 +705,12 @@ export async function renderPagesPageResponse( // this point, so the captured value matches main's original capture site. const userSetCacheControl = responseHeaders.has("Cache-Control"); - if (options.scriptNonce) { + if (options.bypassSharedCache) { + // Checked before the nonce branch: request-derived props need the adapter + // so provider-owned edge headers set by App.getInitialProps are cleared. + applyCdnResponseHeaders(responseHeaders, { cacheControl: ISR_NEVER_CACHE_CONTROL }); + markPagesPrerenderSharedCacheBypass(responseHeaders); + } else if (options.scriptNonce) { responseHeaders.set("Cache-Control", ISR_NO_STORE_CACHE_CONTROL); } else if (options.isrRevalidateSeconds !== null) { // Fresh ISR (MISS) response: route through the CDN adapter with the path tag diff --git a/packages/vinext/src/server/pages-request-pipeline.ts b/packages/vinext/src/server/pages-request-pipeline.ts index 3635e3bde..e889e1c8a 100644 --- a/packages/vinext/src/server/pages-request-pipeline.ts +++ b/packages/vinext/src/server/pages-request-pipeline.ts @@ -34,6 +34,12 @@ import { cloneRequestWithUrl, normalizeTrailingSlash } from "./request-pipeline. import { applyConfigHeadersToHeaderRecord } from "./config-headers.js"; import type { HeaderRecord } from "./request-pipeline.js"; import { mergeHeaders } from "./worker-utils.js"; +import { + applyCdnResponseHeaders, + hasExplicitNonCacheableResponsePolicy, + isNonCacheableCacheControl, + NEVER_CACHE_CONTROL, +} from "./cache-control.js"; import { normalizeDefaultLocalePathname, stripI18nLocaleForApiRoute } from "./pages-i18n.js"; import { mergeRewriteQuery } from "../utils/query.js"; import { addBasePathToPathname, hasBasePath } from "../utils/base-path.js"; @@ -943,7 +949,22 @@ export async function runPagesRequest( renderPageMatch?.route.pattern, ); } + const responseIsExplicitlyNonCacheable = hasExplicitNonCacheableResponsePolicy( + response.headers, + ); + const responseCacheControl = response.headers.get("cache-control"); const merged = mergeHeaders(response, matchedPathHeaders, middlewareStatus); + if (responseIsExplicitlyNonCacheable) { + // A rendered non-cacheable policy is final. Reapply it after staged + // headers merge so adapter-owned cache metadata cannot reappear from + // middleware or next.config headers. + applyCdnResponseHeaders(merged.headers, { + cacheControl: + responseCacheControl && isNonCacheableCacheControl(responseCacheControl) + ? responseCacheControl + : NEVER_CACHE_CONTROL, + }); + } // Preserve the streaming marker so the adapter can decide stream-vs-buffer. // mergeHeaders may create a new Response object (losing non-standard properties), // so we copy the marker from the original render response to the merged one. diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index e3f486f6c..68b6c6bf8 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -67,6 +67,10 @@ import { } from "../utils/asset-prefix.js"; import { computeClientRuntimeMetadata } from "../utils/client-runtime-metadata.js"; import { setPagesClientAssets } from "./pages-client-assets.js"; +import { + assertPagesDataExportCompatibility, + type PagesDataExportModule, +} from "./pages-data-export-compatibility.js"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; import { isUnknownRecord } from "../utils/record.js"; import type { ExecutionContextLike } from "vinext/shims/request-context"; @@ -1884,7 +1888,7 @@ type PagesRouterServerOptions = { type PagesServerEntryPageRoute = { pattern: string; - module?: { + module?: PagesDataExportModule & { getStaticPaths?: (opts: { locales: string[]; defaultLocale: string }) => Promise; }; }; @@ -2039,13 +2043,15 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { const locales: string[] = localesRaw ? JSON.parse(localesRaw) : []; const defaultLocale = parsedUrl.searchParams.get("defaultLocale") ?? ""; const route = pageRoutes?.find((r) => r.pattern === pattern); - const fn = route?.module?.getStaticPaths; - if (typeof fn !== "function") { - res.writeHead(204); - res.end(); - return; - } + const pageModule = route?.module; + const fn = pageModule?.getStaticPaths; try { + assertPagesDataExportCompatibility(pageModule ?? {}, pattern); + if (typeof fn !== "function") { + res.writeHead(204); + res.end(); + return; + } const result = await fn({ locales, defaultLocale }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(result)); diff --git a/tests/app-prerender-endpoints.test.ts b/tests/app-prerender-endpoints.test.ts index 91170fca5..3821509ed 100644 --- a/tests/app-prerender-endpoints.test.ts +++ b/tests/app-prerender-endpoints.test.ts @@ -6,6 +6,8 @@ import { getRootParam } from "../packages/vinext/src/shims/root-params.js"; type TestPageRoute = { pattern: string; module?: { + default?: unknown; + getStaticProps?: () => unknown; getStaticPaths?: (opts: { locales: string[]; defaultLocale: string }) => unknown; }; }; @@ -231,6 +233,35 @@ describe("App prerender endpoint helpers", () => { }); }); + it("rejects incompatible page exports before empty static paths can skip rendering", async () => { + const Page = Object.assign(() => null, { getInitialProps: async () => ({}) }); + const getStaticPaths = vi.fn(() => ({ fallback: "blocking", paths: [] })); + const response = await handleAppPrerenderEndpoint( + new Request("http://localhost/__vinext/prerender/pages-static-paths?pattern=/posts/:id"), + { + isPrerenderEnabled: () => true, + loadPagesRoutes: async () => [ + { + module: { + default: Page, + getStaticPaths, + getStaticProps: () => ({ props: {} }), + }, + pattern: "/posts/:id", + }, + ], + pathname: "/__vinext/prerender/pages-static-paths", + staticParamsMap: {}, + }, + ); + + expect(getStaticPaths).not.toHaveBeenCalled(); + expect(response?.status).toBe(500); + await expect(response?.json()).resolves.toMatchObject({ + error: expect.stringContaining("You can not use getInitialProps with getStaticProps"), + }); + }); + it("returns no content when the requested prerender function is absent", async () => { const staticParamsResponse = await handleAppPrerenderEndpoint( new Request("http://localhost/__vinext/prerender/static-params?pattern=/missing"), diff --git a/tests/cloudflare-cdn-cache.test.ts b/tests/cloudflare-cdn-cache.test.ts index 826b3ff18..9906359fd 100644 --- a/tests/cloudflare-cdn-cache.test.ts +++ b/tests/cloudflare-cdn-cache.test.ts @@ -375,6 +375,7 @@ describe("CloudflareCdnCacheAdapter", () => { // a CDN-Cache-Control directive (which would cache an uncacheable response). for (const cc of [ "no-store, must-revalidate", + "NO-STORE", "private, no-cache, no-store, max-age=0, must-revalidate", ]) { const headers = adapter.buildResponseHeaders({ cacheControl: cc, tags: ["x"] }); diff --git a/tests/pages-page-data.test.ts b/tests/pages-page-data.test.ts index 04fd015d9..fbff11339 100644 --- a/tests/pages-page-data.test.ts +++ b/tests/pages-page-data.test.ts @@ -326,7 +326,114 @@ describe("pages page data", () => { }); }); - it("preserves custom app props during stale ISR regeneration", async () => { + it("does not cache request-derived _app props for getStaticProps pages", async () => { + const isrGet = vi.fn().mockResolvedValue({ + isStale: false, + value: { + cacheControl: { revalidate: 60 }, + value: { + kind: "PAGES", + html: "cached private response", + pageData: { appSecret: "sid=alice-private", pageProps: { cached: true } }, + }, + }, + }); + const isrSet = vi.fn(async () => {}); + const appGetInitialProps = vi.fn( + ({ ctx }: { ctx: { req: { headers: { cookie: string } } } }) => ({ + appSecret: ctx.req.headers.cookie, + pageProps: {}, + }), + ); + + const result = await resolvePagesPageData( + createOptions({ + AppComponent: Object.assign(function App() {}, { + getInitialProps: appGetInitialProps, + }), + createGsspReqRes() { + return { + req: { headers: { cookie: "sid=bob-private" } }, + res: { + headersSent: false, + statusCode: 200, + getHeaders() { + return {}; + }, + }, + responsePromise: Promise.resolve(new Response()), + }; + }, + isrGet, + isrSet, + pageModule: { + default: function Page() {}, + getStaticProps() { + return { props: { fresh: true }, revalidate: 60 }; + }, + }, + }), + ); + + expect(result).toMatchObject({ + kind: "render", + isrRevalidateSeconds: null, + props: { + appSecret: "sid=bob-private", + pageProps: { fresh: true }, + }, + }); + expect(appGetInitialProps).toHaveBeenCalledOnce(); + expect(isrGet).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + }); + + it("does not cache request-derived _app props during on-demand revalidation", async () => { + const isrGet = vi.fn().mockResolvedValue({ + isStale: true, + value: { + cacheControl: { revalidate: 60 }, + value: { + kind: "PAGES", + html: "cached private response", + pageData: { appSecret: "sid=alice-private", pageProps: { cached: true } }, + }, + }, + }); + const isrSet = vi.fn(async () => {}); + + const result = await resolvePagesPageData( + createOptions({ + AppComponent: Object.assign(function App() {}, { + getInitialProps() { + return { appSecret: "sid=bob-private", pageProps: {} }; + }, + }), + isOnDemandRevalidate: true, + isrGet, + isrSet, + pageModule: { + default: function Page() {}, + getStaticProps() { + return { props: { fresh: true }, revalidate: 60 }; + }, + }, + }), + ); + + expect(result).toMatchObject({ + kind: "render", + isrRevalidateSeconds: null, + props: { + appSecret: "sid=bob-private", + pageProps: { fresh: true }, + }, + }); + expect(isrGet).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + }); + + it("bypasses stale ISR entries when a custom app supplies request-aware props", async () => { const isrSet = vi.fn(async () => {}); const createPageElement = vi.fn(() => "page"); let requestContextsApplied = false; @@ -362,7 +469,7 @@ describe("pages page data", () => { pageModule: { default: function Page() {}, getStaticProps() { - expect(requestContextsApplied).toBe(true); + expect(requestContextsApplied).toBe(false); return { props: { fromStatic: true }, revalidate: 10 }; }, }, @@ -372,22 +479,18 @@ describe("pages page data", () => { }), ); - expect(result.kind).toBe("response"); - await regenerationPromise; - expect(createPageElement).toHaveBeenCalledWith({ - appValue: "fresh-app", - pageProps: { fromApp: true, fromStatic: true }, + expect(result).toMatchObject({ + kind: "render", + isrRevalidateSeconds: null, + props: { + appValue: "fresh-app", + pageProps: { fromApp: true, fromStatic: true }, + }, }); - expect(isrSet).toHaveBeenCalledWith( - "pages:/posts/post", - expect.objectContaining({ - pageData: { - appValue: "fresh-app", - pageProps: { fromApp: true, fromStatic: true }, - }, - }), - { cacheControl: { revalidate: 10, expire: 300 } }, - ); + expect(appGetInitialPropsCalls).toBe(1); + expect(regenerationPromise).toBeUndefined(); + expect(createPageElement).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); }); it("returns a notFound signal when getStaticPaths excludes a dynamic HTML path", async () => { @@ -1393,7 +1496,7 @@ describe("pages page data", () => { ); }); - it("preserves _app.getInitialProps app-level props during stale ISR regeneration", async () => { + it("does not regenerate stale ISR entries with _app.getInitialProps props", async () => { let regenPromise: Promise | null = null; const isrSet = vi.fn(async () => {}); const triggerBackgroundRegeneration = vi.fn((_key: string, renderFn: () => Promise) => { @@ -1448,43 +1551,21 @@ describe("pages page data", () => { }), ); - expect(result.kind).toBe("response"); - if (result.kind !== "response") { - throw new Error("expected response result"); - } - expect(result.response.headers.get("x-vinext-cache")).toBe("STALE"); - - if (!regenPromise) { - throw new Error("expected stale ISR regeneration to start"); - } - const pendingRegen: Promise = regenPromise; - await pendingRegen; - - expect(capturedRenderProps).toEqual( - expect.objectContaining({ + expect(result).toMatchObject({ + kind: "render", + isrRevalidateSeconds: null, + props: { appProp: "from-app", pageProps: { pageProp: "from-page" }, - }), - ); - - expect(isrSet).toHaveBeenCalledOnce(); - const regeneratedCacheValue = isrSet.mock.calls[0]?.[1]; - expect(regeneratedCacheValue).toEqual( - expect.objectContaining({ - kind: "PAGES", - pageData: { - appProp: "from-app", - pageProps: { pageProp: "from-page" }, - }, - }), - ); - if (regeneratedCacheValue?.kind !== "PAGES") throw new Error("expected PAGES cache value"); - expect(regeneratedCacheValue?.html).toContain('"appProp":"from-app"'); - expect(regeneratedCacheValue?.html).toContain('"pageProp":"from-page"'); - expect(regeneratedCacheValue?.html).toContain('"page":"/posts/[slug]"'); + }, + }); + expect(regenPromise).toBeNull(); + expect(capturedRenderProps).toBeUndefined(); + expect(triggerBackgroundRegeneration).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); }); - it("does not run _app.getInitialProps on a fresh ISR cache HIT", async () => { + it("bypasses fresh ISR cache HITs when _app.getInitialProps is present", async () => { const appGip = vi.fn().mockResolvedValue({ appProp: "from-app", pageProps: {}, @@ -1521,15 +1602,199 @@ describe("pages page data", () => { }), ); + expect(result).toMatchObject({ + kind: "render", + isrRevalidateSeconds: null, + props: { + appProp: "from-app", + pageProps: { pageProp: "fresh" }, + }, + }); + expect(appGip).toHaveBeenCalledOnce(); + }); + + it("keeps ISR for an App that inherits the shim's default getInitialProps", async () => { + const isrGet = vi.fn().mockResolvedValue(null); + const defaultGip = vi.fn().mockResolvedValue({ pageProps: {} }); + const result = await resolvePagesPageData( + createOptions({ + // Mirrors `class MyApp extends App {}`: the static getInitialProps is + // the shim default, not a userland override. + AppComponent: Object.assign( + function App() { + return null; + }, + { getInitialProps: defaultGip, origGetInitialProps: defaultGip }, + ), + isrGet, + pageModule: { + async getStaticProps() { + return { props: { pageProp: "fresh" }, revalidate: 60 }; + }, + }, + }), + ); + + expect(result).toMatchObject({ kind: "render", isrRevalidateSeconds: 60 }); + expect(isrGet).toHaveBeenCalledWith("pages:/posts/post"); + }); + + it("rejects page getInitialProps combined with getStaticProps", async () => { + const Page = Object.assign( + function Page() { + return null; + }, + { getInitialProps: vi.fn().mockResolvedValue({ viewer: "me" }) }, + ); + + await expect( + resolvePagesPageData( + createOptions({ + pageModule: { + default: Page, + getStaticProps: vi.fn().mockResolvedValue({ props: {} }), + }, + }), + ), + ).rejects.toThrow( + "You can not use getInitialProps with getStaticProps. To use SSG, please remove your getInitialProps /posts/[slug]", + ); + }); + + it("skips the shared cache probe for request-aware fallback shells", async () => { + const isrGet = vi.fn().mockResolvedValue({ + isStale: false, + value: { + cacheControl: { revalidate: 60 }, + value: { + kind: "PAGES", + html: "legacy cached page", + pageData: { pageProps: { cached: true } }, + }, + }, + }); + const result = await resolvePagesPageData( + createOptions({ + AppComponent: Object.assign( + function App() { + return null; + }, + { getInitialProps: vi.fn().mockResolvedValue({ viewer: "me", pageProps: {} }) }, + ), + pageModule: { + getStaticPaths() { + return { paths: [], fallback: true }; + }, + async getStaticProps() { + return { props: { pageProp: "fresh" }, revalidate: 60 }; + }, + }, + isrGet, + route: { isDynamic: true }, + routePattern: "/posts/[slug]", + routeUrl: "/posts/unseen", + }), + ); + + expect(result).toMatchObject({ + kind: "render", + isFallback: true, + bypassSharedCache: true, + props: { viewer: "me" }, + }); + expect(isrGet).not.toHaveBeenCalled(); + }); + + it("returns an uncacheable notFound when a custom App supplies request-aware props", async () => { + const isrSet = vi.fn(async () => {}); + const result = await resolvePagesPageData( + createOptions({ + AppComponent: Object.assign( + function App() { + return null; + }, + { getInitialProps: vi.fn().mockResolvedValue({ pageProps: {} }) }, + ), + isrSet, + pageModule: { + async getStaticProps() { + return { notFound: true, revalidate: 60 }; + }, + }, + }), + ); + + expect(result).toEqual({ + kind: "notFound", + bypassSharedCache: true, + revalidateSeconds: undefined, + expireSeconds: undefined, + cacheState: undefined, + responseHeaders: undefined, + }); + expect(isrSet).not.toHaveBeenCalled(); + }); + + it("marks request-aware fallback false path misses as bypassing shared cache", async () => { + const result = await resolvePagesPageData( + createOptions({ + AppComponent: Object.assign( + function App() { + return null; + }, + { getInitialProps: vi.fn().mockResolvedValue({ pageProps: {} }) }, + ), + pageModule: { + getStaticPaths() { + return { paths: [], fallback: false }; + }, + async getStaticProps() { + return { props: {}, revalidate: 60 }; + }, + }, + route: { isDynamic: true }, + routePattern: "/posts/[slug]", + routeUrl: "/posts/unlisted", + }), + ); + + expect(result).toEqual({ + kind: "notFound", + bypassSharedCache: true, + revalidateSeconds: undefined, + expireSeconds: undefined, + cacheState: undefined, + responseHeaders: undefined, + }); + }); + + it("treats only-generated revalidation as a no-op when a custom App supplies request-aware props", async () => { + const isrGet = vi.fn().mockResolvedValue(null); + const getStaticProps = vi.fn(async () => ({ props: { pageProp: "fresh" }, revalidate: 60 })); + const result = await resolvePagesPageData( + createOptions({ + AppComponent: Object.assign( + function App() { + return null; + }, + { getInitialProps: vi.fn().mockResolvedValue({ pageProps: {} }) }, + ), + isOnDemandRevalidate: true, + revalidateOnlyGenerated: true, + isrGet, + pageModule: { getStaticProps }, + }), + ); + expect(result.kind).toBe("response"); - if (result.kind !== "response") { - throw new Error("expected response result"); - } - expect(result.response.headers.get("x-vinext-cache")).toBe("HIT"); - expect(appGip).not.toHaveBeenCalled(); + if (result.kind !== "response") throw new Error("expected response result"); + expect(result.response.status).toBe(404); + expect(result.response.headers.get("x-nextjs-cache")).toBe("REVALIDATED"); + expect(isrGet).not.toHaveBeenCalled(); + expect(getStaticProps).not.toHaveBeenCalled(); }); - it("only runs _app.getInitialProps in the stale ISR regeneration path, not on the immediate stale response", async () => { + it("renders request-aware app props in the foreground instead of serving stale ISR HTML", async () => { let regenPromise: Promise | null = null; const isrSet = vi.fn(async () => {}); const triggerBackgroundRegeneration = vi.fn((_key: string, renderFn: () => Promise) => { @@ -1588,24 +1853,20 @@ describe("pages page data", () => { }), ); - expect(result.kind).toBe("response"); - if (result.kind !== "response") { - throw new Error("expected response result"); - } - expect(result.response.headers.get("x-vinext-cache")).toBe("STALE"); - // App GIP must not run before serving the stale response. - expect(foregroundGipCalls).toBe(0); - - if (!regenPromise) { - throw new Error("expected stale ISR regeneration to start"); - } - const pendingRegen: Promise = regenPromise; - await pendingRegen; - - // App GIP must run exactly once, inside the background regeneration callback. - expect(regenGipCalls).toBe(1); + expect(result).toMatchObject({ + kind: "render", + isrRevalidateSeconds: null, + props: { + appProp: "from-app", + pageProps: { pageProp: "from-page" }, + }, + }); + expect(foregroundGipCalls).toBe(1); + expect(regenGipCalls).toBe(0); + expect(regenPromise).toBeNull(); expect(appGip).toHaveBeenCalledOnce(); - expect(isrSet).toHaveBeenCalledOnce(); + expect(triggerBackgroundRegeneration).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); }); it("preserves vinext module metadata during stale ISR regeneration", async () => { @@ -1734,6 +1995,7 @@ describe("pages page data", () => { gsspRes: null, isrExpireSeconds: 300, isrRevalidateSeconds: 30, + bypassSharedCache: false, pageProps: { title: "hello" }, props: { pageProps: { title: "hello" } }, isFallback: false, diff --git a/tests/pages-page-handler.test.ts b/tests/pages-page-handler.test.ts index 7dfce366e..40553d3c3 100644 --- a/tests/pages-page-handler.test.ts +++ b/tests/pages-page-handler.test.ts @@ -27,7 +27,11 @@ import { PRERENDER_REVALIDATE_HEADER, } from "../packages/vinext/src/server/isr-cache.js"; import { after } from "../packages/vinext/src/shims/server.js"; -import { VINEXT_REVALIDATED_CACHE_TAG_HEADER } from "../packages/vinext/src/server/headers.js"; +import { + VINEXT_PRERENDER_RENDER_ERROR_HEADER, + VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER, + VINEXT_REVALIDATED_CACHE_TAG_HEADER, +} from "../packages/vinext/src/server/headers.js"; afterEach(() => setCdnCacheAdapter(new DefaultCdnCacheAdapter())); @@ -437,6 +441,297 @@ describe("createPagesPageHandler — no default export", () => { // _next/data JSON envelope // --------------------------------------------------------------------------- +describe("createPagesPageHandler — request-aware App props", () => { + const AppComponent = Object.assign(() => null, { + getInitialProps: async () => ({ viewer: "per-request", pageProps: {} }), + }); + function makeHandler(pageModule: Record) { + return createPagesPageHandler( + makeOpts({ + AppComponent, + pageRoutes: [makeRoute("/about", pageModule)], + matchRoute: (url, r) => { + const route = r.find((rt) => rt.pattern === url.split("?")[0]); + return route ? { route, params: {} } : null; + }, + }), + ); + } + + it("marks getStaticProps HTML and data responses as private no-store", async () => { + const handler = makeHandler( + makePageModule({ getStaticProps: async () => ({ props: { a: 1 }, revalidate: 60 }) }), + ); + + const html = await handler(makeRequest("/about"), "/about", null, null, null); + expect(html.status).toBe(200); + expect(html.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + expect(html.headers.get("x-nextjs-cache")).toBeNull(); + expect(html.headers.get(VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER)).toBeNull(); + + const dataUrl = "/_next/data/test-build-id/about.json"; + const data = await handler(makeRequest(dataUrl), dataUrl, null, null, null); + expect(data.status).toBe(200); + expect(data.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + expect(data.headers.get(VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER)).toBeNull(); + }); + + it("clears adapter-owned edge cache headers set by App.getInitialProps", async () => { + setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); + const EdgeApp = Object.assign(() => null, { + getInitialProps: async ({ + ctx, + }: { + ctx: { res: { setHeader(n: string, v: string): void } }; + }) => { + ctx.res.setHeader("CDN-Cache-Control", "s-maxage=600"); + return { viewer: "per-request", pageProps: {} }; + }, + }); + const handler = createPagesPageHandler( + makeOpts({ + AppComponent: EdgeApp, + pageRoutes: [ + makeRoute( + "/about", + makePageModule({ getStaticProps: async () => ({ props: {}, revalidate: 60 }) }), + ), + ], + matchRoute: (url, r) => { + const route = r.find((rt) => rt.pattern === url.split("?")[0]); + return route ? { route, params: {} } : null; + }, + }), + ); + + const html = await handler(makeRequest("/about"), "/about", null, null, null); + expect(html.status).toBe(200); + expect(html.headers.get("cdn-cache-control")).toBeNull(); + expect(html.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + it("clears edge cache headers even for nonce-bearing renders", async () => { + setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); + const EdgeApp = Object.assign(() => null, { + getInitialProps: async ({ + ctx, + }: { + ctx: { res: { setHeader(n: string, v: string): void } }; + }) => { + ctx.res.setHeader("CDN-Cache-Control", "s-maxage=600"); + return { viewer: "per-request", pageProps: {} }; + }, + }); + const handler = createPagesPageHandler( + makeOpts({ + AppComponent: EdgeApp, + pageRoutes: [ + makeRoute( + "/about", + makePageModule({ getStaticProps: async () => ({ props: {}, revalidate: 60 }) }), + ), + ], + matchRoute: (url, r) => { + const route = r.find((rt) => rt.pattern === url.split("?")[0]); + return route ? { route, params: {} } : null; + }, + }), + ); + + const request = new Request("http://localhost/about", { + headers: { "content-security-policy": "script-src 'nonce-abc123'" }, + }); + const html = await handler(request, "/about", null, null, null); + expect(html.status).toBe(200); + expect(html.headers.get("cdn-cache-control")).toBeNull(); + expect(html.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + it("clears edge cache headers when App.getInitialProps ends the response", async () => { + setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); + const EdgeApp = Object.assign(() => null, { + getInitialProps: async ({ + ctx, + }: { + ctx: { + res: { + end(body: string): void; + setHeader(name: string, value: string): void; + }; + }; + }) => { + ctx.res.setHeader("CDN-Cache-Control", "s-maxage=600"); + ctx.res.end("request-aware short circuit"); + return { viewer: "per-request", pageProps: {} }; + }, + }); + const handler = createPagesPageHandler( + makeOpts({ + AppComponent: EdgeApp, + pageRoutes: [ + makeRoute( + "/about", + makePageModule({ getStaticProps: async () => ({ props: {}, revalidate: 60 }) }), + ), + ], + matchRoute: (url, routes) => { + const route = routes.find((candidate) => candidate.pattern === url.split("?")[0]); + return route ? { route, params: {} } : null; + }, + }), + ); + + const response = await handler(makeRequest("/about"), "/about", null, null, null); + expect(response.status).toBe(200); + expect(await response.text()).toBe("request-aware short circuit"); + expect(response.headers.get("cdn-cache-control")).toBeNull(); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + it("clears edge cache headers when Document.getInitialProps ends the response", async () => { + setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); + const EdgeDocument = Object.assign(() => null, { + getInitialProps: async ({ + res, + }: { + res: { + end(body: string): void; + setHeader(name: string, value: string): void; + }; + }) => { + res.setHeader("CDN-Cache-Control", "s-maxage=600"); + res.end("request-aware document short circuit"); + return { html: "ignored after res.end" }; + }, + }); + const handler = createPagesPageHandler( + makeOpts({ + AppComponent, + DocumentComponent: EdgeDocument, + pageRoutes: [ + makeRoute( + "/about", + makePageModule({ getStaticProps: async () => ({ props: {}, revalidate: 60 }) }), + ), + ], + }), + ); + + const response = await handler(makeRequest("/about"), "/about", null, null, null); + expect(response.status).toBe(200); + expect(await response.text()).toBe("request-aware document short circuit"); + expect(response.headers.get("cdn-cache-control")).toBeNull(); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + it("still rejects an invalid revalidate value", async () => { + const handler = makeHandler( + makePageModule({ getStaticProps: async () => ({ props: {}, revalidate: 0 }) }), + ); + const res = await handler(makeRequest("/about"), "/about", null, null, null); + expect(res.status).toBe(500); + }); + + it("carries request-aware cache bypass into a recursive 500 render", async () => { + setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); + const EdgeApp = Object.assign(() => null, { + getInitialProps: async ({ + ctx, + }: { + ctx: { res: { setHeader(name: string, value: string): void } }; + }) => { + ctx.res.setHeader("CDN-Cache-Control", "s-maxage=600"); + return { viewer: "per-request", pageProps: {} }; + }, + }); + const handler = createPagesPageHandler( + makeOpts({ + AppComponent: EdgeApp, + pageRoutes: [ + makeRoute( + "/about", + makePageModule({ + getStaticProps: async () => { + throw new Error("request-aware data failed"); + }, + }), + ), + makeRoute("/500"), + ], + }), + ); + + for (const contentSecurityPolicy of [undefined, "script-src 'nonce-test-nonce'"]) { + const response = await handler( + new Request("http://localhost/about", { + headers: contentSecurityPolicy + ? { "content-security-policy": contentSecurityPolicy } + : undefined, + }), + "/about", + null, + null, + null, + ); + + expect(response.status).toBe(500); + expect(response.headers.get("cdn-cache-control")).toBeNull(); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + } + }); +}); + +describe("createPagesPageHandler — data export compatibility", () => { + it("marks the invalid getInitialProps and getStaticProps combination fatal only during prerender", async () => { + const Page = Object.assign(() => null, { + getInitialProps: async () => ({ pageProps: {} }), + }); + const handler = createPagesPageHandler( + makeOpts({ + pageRoutes: [ + makeRoute( + "/about", + makePageModule({ + default: Page, + getStaticProps: async () => ({ props: {} }), + }), + ), + ], + }), + ); + const savedPrerender = process.env.VINEXT_PRERENDER; + + try { + delete process.env.VINEXT_PRERENDER; + const runtimeResponse = await handler(makeRequest("/about"), "/about", null, null, null); + expect(runtimeResponse.status).toBe(500); + expect(runtimeResponse.headers.get(VINEXT_PRERENDER_RENDER_ERROR_HEADER)).toBeNull(); + + process.env.VINEXT_PRERENDER = "1"; + const prerenderResponse = await handler(makeRequest("/about"), "/about", null, null, null); + expect(prerenderResponse.status).toBe(500); + expect(prerenderResponse.headers.get(VINEXT_PRERENDER_RENDER_ERROR_HEADER)).toBe("1"); + } finally { + if (savedPrerender === undefined) delete process.env.VINEXT_PRERENDER; + else process.env.VINEXT_PRERENDER = savedPrerender; + } + }); +}); + describe("createPagesPageHandler — _next/data", () => { it("detects /_next/data URL and returns JSON envelope", async () => { const routes = [makeRoute("/about")]; @@ -918,6 +1213,39 @@ describe("createPagesPageHandler — preview responses", () => { expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate"); expect(response.headers.get("cache-control")).not.toContain("s-maxage"); }); + + it("propagates request-aware cache bypass through a nonce-bearing custom 404", async () => { + setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); + const AppComponent = Object.assign(() => null, { + getInitialProps: async ({ + ctx, + }: { + ctx: { res: { setHeader(name: string, value: string): void } }; + }) => { + ctx.res.setHeader("CDN-Cache-Control", "s-maxage=600"); + return { viewer: "per-request", pageProps: {} }; + }, + }); + const sourceRoute = makeRoute( + "/missing", + makePageModule({ getStaticProps: async () => ({ notFound: true, revalidate: 7 }) }), + ); + const notFoundRoute = makeRoute("/404"); + const handler = createPagesPageHandler( + makeOpts({ AppComponent, pageRoutes: [sourceRoute, notFoundRoute] }), + ); + const request = new Request("http://localhost/missing", { + headers: { "content-security-policy": "script-src 'nonce-test-nonce'" }, + }); + + const response = await handler(request, "/missing", null, null, null); + + expect(response.status).toBe(404); + expect(response.headers.get("cdn-cache-control")).toBeNull(); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/pages-request-pipeline.test.ts b/tests/pages-request-pipeline.test.ts index d2d289fbc..3aadcdfa8 100644 --- a/tests/pages-request-pipeline.test.ts +++ b/tests/pages-request-pipeline.test.ts @@ -8,6 +8,11 @@ import { } from "../packages/vinext/src/server/pages-request-pipeline.js"; import { MIDDLEWARE_SKIP_HEADER } from "../packages/vinext/src/server/headers.js"; import { PRERENDER_REVALIDATE_HEADER } from "../packages/vinext/src/utils/protocol-headers.js"; +import { + DefaultCdnCacheAdapter, + setCdnCacheAdapter, +} from "../packages/vinext/src/shims/cdn-cache.js"; +import { CloudflareCdnCacheAdapter } from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js"; import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js"; import { CACHEABILITY_REQUEST_STATE, @@ -50,6 +55,103 @@ function makeRenderPage(status = 200, body = "ok") { ); } +describe("final cache header merge", () => { + it("keeps a rendered no-store policy authoritative over staged CDN headers", async () => { + setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); + try { + const result = await runPagesRequest( + makeRequest("/private"), + baseDeps({ + hasMiddleware: true, + runMiddleware: makeMiddleware({ + responseHeaders: [ + ["CDN-Cache-Control", "s-maxage=600"], + ["Cloudflare-CDN-Cache-Control", "s-maxage=600"], + ["Cache-Tag", "private-page"], + ], + }), + renderPage: vi.fn( + async () => + new Response("private", { + headers: { + "Cache-Control": "private, no-cache, no-store, max-age=0, must-revalidate", + }, + }), + ), + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(result.response.headers.get("cdn-cache-control")).toBeNull(); + expect(result.response.headers.get("cloudflare-cdn-cache-control")).toBeNull(); + expect(result.response.headers.get("cache-tag")).toBeNull(); + expect(result.response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + } finally { + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); + } + }); + + it("does not promote a provider no-store policy from a cacheable browser header", async () => { + setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); + try { + const result = await runPagesRequest( + makeRequest("/private"), + baseDeps({ + renderPage: vi.fn( + async () => + new Response("private", { + headers: { + "Cache-Control": "public, max-age=3600", + "CDN-Cache-Control": "no-store", + }, + }), + ), + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(result.response.headers.get("cdn-cache-control")).toBeNull(); + expect(result.response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + } finally { + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); + } + }); + + it("preserves a rendered private browser policy while clearing staged CDN headers", async () => { + setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); + try { + const result = await runPagesRequest( + makeRequest("/private"), + baseDeps({ + hasMiddleware: true, + runMiddleware: makeMiddleware({ + responseHeaders: [["CDN-Cache-Control", "s-maxage=600"]], + }), + renderPage: vi.fn( + async () => + new Response("private", { + headers: { "Cache-Control": "private, max-age=300" }, + }), + ), + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(result.response.headers.get("cdn-cache-control")).toBeNull(); + expect(result.response.headers.get("cache-control")).toBe("private, max-age=300"); + } finally { + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); + } + }); +}); + async function cacheabilityReasonFor( request: Request, overrides: Partial, diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 8ea7c6aad..32292b2bd 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -2776,6 +2776,37 @@ export default class CustomDocument extends Document { expect(nextData.props).toEqual({ pageProps: {} }); }); + it.each([true, false])( + "rejects getInitialProps with getStaticProps before a dev fallback: %s exit", + async (fallback) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-invalid-fallback-exports-")); + fs.mkdirSync(path.join(tmpDir, "pages", "products"), { recursive: true }); + fs.symlinkSync(path.join(process.cwd(), "node_modules"), path.join(tmpDir, "node_modules")); + fs.writeFileSync(path.join(tmpDir, "pages", "_app.tsx"), PAGES_APP_COMPONENT); + fs.writeFileSync( + path.join(tmpDir, "pages", "products", "[id].tsx"), + `export default function Product() { return

fallback product

; } +Product.getInitialProps = async () => ({ fromInitialProps: true }); +export function getStaticPaths() { return { paths: [], fallback: ${fallback} }; } +export function getStaticProps() { return { props: { fromStaticProps: true } }; } +`, + ); + + let tempServer: ViteDevServer | undefined; + try { + const started = await startFixtureServer(tmpDir); + tempServer = started.server; + + const response = await fetch(`${started.baseUrl}/products/unlisted`); + expect(response.status).toBe(500); + expect(await response.text()).not.toContain("fallback product"); + } finally { + await tempServer?.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + it("resolves real props for the data URL of an unlisted fallback: true path", async () => { // Counterpart to the fallback-shell test: the page HTML ships empty props, // but the client follows up with `/_next/data//products/unknown.json` @@ -9870,34 +9901,35 @@ describe("custom App optional pageProps envelope parity", () => { }); } - // Next.js renders App with `{ ...props, router }`. Production verifies the - // ISR MISS -> HIT transition; development reruns GSP for each request. + // Next.js renders App with `{ ...props, router }`. A custom App can derive + // props from the live request, so production must bypass shared ISR while + // both modes preserve the merged props across repeated GSP renders. // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/render.tsx // https://github.com/vercel/next.js/blob/canary/test/e2e/prerender.test.ts for (const mode of ["dev", "prod"] as const) { it(`merges primitive App props and preserves router across repeated GSP renders in ${mode}`, async () => { const baseUrl = mode === "dev" ? devUrl : prodUrl; - const missResponse = await fetch(`${baseUrl}/gsp-string`); - expect(missResponse.status).toBe(200); - expect(missResponse.headers.get("x-nextjs-cache")).toBe(mode === "dev" ? "HIT" : "MISS"); + const firstResponse = await fetch(`${baseUrl}/gsp-string`); + expect(firstResponse.status).toBe(200); + expect(firstResponse.headers.get("x-nextjs-cache")).toBe(mode === "dev" ? "HIT" : null); if (mode === "dev") { - expect(missResponse.headers.get("cache-control")).toBe("no-cache, must-revalidate"); + expect(firstResponse.headers.get("cache-control")).toBe("no-cache, must-revalidate"); } - const missHtml = await missResponse.text(); - expect(missHtml).toContain( + const firstHtml = await firstResponse.text(); + expect(firstHtml).toContain( '
{"0":"h","1":"i","fromData":"gsp"}
', ); - expect(missHtml).toContain('
/gsp-string
'); + expect(firstHtml).toContain('
/gsp-string
'); - const hitResponse = await fetch(`${baseUrl}/gsp-string`); - expect(hitResponse.status).toBe(200); - expect(hitResponse.headers.get("x-nextjs-cache")).toBe("HIT"); - const hitHtml = await hitResponse.text(); - expect(hitHtml).toContain( + const secondResponse = await fetch(`${baseUrl}/gsp-string`); + expect(secondResponse.status).toBe(200); + expect(secondResponse.headers.get("x-nextjs-cache")).toBe(mode === "dev" ? "HIT" : null); + const secondHtml = await secondResponse.text(); + expect(secondHtml).toContain( '
{"0":"h","1":"i","fromData":"gsp"}
', ); - expect(hitHtml).toContain('
/gsp-string
'); + expect(secondHtml).toContain('
/gsp-string
'); }); } }); diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index 60053a96a..9a55005e2 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -21,7 +21,11 @@ import { type PrerenderRouteResult, type StaticParamsMap, } from "../packages/vinext/src/build/prerender.js"; -import { VINEXT_PRERENDER_SPECULATIVE_HEADER } from "../packages/vinext/src/server/headers.js"; +import { + VINEXT_PRERENDER_RENDER_ERROR_HEADER, + VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER, + VINEXT_PRERENDER_SPECULATIVE_HEADER, +} from "../packages/vinext/src/server/headers.js"; import { safeJsonStringify } from "../packages/vinext/src/server/html.js"; import type { AppRoute } from "../packages/vinext/src/routing/app-router.js"; import { @@ -776,6 +780,115 @@ describe("prerenderApp — RSC extraction", () => { // ─── Pages Router ───────────────────────────────────────────────────────────── +describe("prerenderPages — response cache policy", () => { + it.each(["default", "export"] as const)( + "handles non-cacheable Pages responses safely in %s mode", + async (mode) => { + const root = tmpDir("vinext-prerender-pages-no-store-"); + const pagesDir = path.join(root, "pages"); + const outDir = path.join(root, "out"); + fs.mkdirSync(pagesDir, { recursive: true }); + fs.writeFileSync( + path.join(pagesDir, "index.tsx"), + `export default function Page() { return null; } +export function getStaticProps() { return { props: {}, revalidate: 60 }; } +`, + ); + fs.writeFileSync( + path.join(pagesDir, "404.tsx"), + `export default function NotFound() { return null; } +`, + ); + fs.writeFileSync( + path.join(pagesDir, "failure.tsx"), + `export default function Failure() { return null; } +`, + ); + fs.writeFileSync( + path.join(pagesDir, "fatal.tsx"), + `export default function Fatal() { return null; } +`, + ); + fs.writeFileSync( + path.join(pagesDir, "cacheable.tsx"), + `export default function Cacheable() { return null; } +`, + ); + + const server = createServer((req, res) => { + res.setHeader("content-type", "text/html"); + res.setHeader("cache-control", "private, no-cache, no-store, max-age=0"); + if (req.url === "/" || req.url === "/404") { + res.setHeader(VINEXT_PRERENDER_SHARED_CACHE_BYPASS_HEADER, "1"); + } + if (req.url === "/404") res.statusCode = 404; + if (req.url === "/failure") res.statusCode = 500; + if (req.url === "/fatal") { + res.statusCode = 500; + res.setHeader(VINEXT_PRERENDER_RENDER_ERROR_HEADER, "1"); + } + if (req.url === "/cacheable") res.setHeader("cache-control", "no-cache"); + res.end("request-aware build props"); + }); + const port = await listen(server); + + try { + const { prerenderPages } = await import("../packages/vinext/src/build/prerender.js"); + const { pagesRouter, apiRouter } = + await import("../packages/vinext/src/routing/pages-router.js"); + const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js"); + const routes = await pagesRouter(pagesDir); + const apiRoutes = await apiRouter(pagesDir); + const config = await resolveNextConfig({}); + + const result = await prerenderPages({ + mode, + routes, + apiRoutes, + pagesDir, + outDir, + config, + _prodServer: { server, port }, + }); + + const nonCacheableResults = result.routes.filter( + (route) => route.route === "/" || route.route === "/404", + ); + expect(nonCacheableResults.length).toBeGreaterThanOrEqual(2); + for (const route of nonCacheableResults) { + if (mode === "export") { + expect(route).toMatchObject({ + status: "error", + error: expect.stringContaining("not supported with output: 'export'"), + }); + } else { + expect(route).toMatchObject({ status: "skipped", reason: "dynamic" }); + } + } + expect(findRoute(result.routes, "/failure")).toMatchObject({ + route: "/failure", + status: "error", + }); + expect(findRoute(result.routes, "/fatal")).toMatchObject({ + route: "/fatal", + status: "error", + fatal: true, + }); + expect(findRoute(result.routes, "/cacheable")).toMatchObject({ + route: "/cacheable", + status: "rendered", + }); + expect(fs.existsSync(path.join(outDir, "index.html"))).toBe(false); + expect(fs.existsSync(path.join(outDir, "404.html"))).toBe(false); + expect(fs.existsSync(path.join(outDir, "cacheable.html"))).toBe(true); + } finally { + await closeServer(server); + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); +}); + describe("prerenderPages — basePath export", () => { it("requests and writes Pages exports under basePath", async () => { const root = tmpDir("vinext-prerender-pages-basepath-"); diff --git a/tests/prod-server-prerender-endpoints.test.ts b/tests/prod-server-prerender-endpoints.test.ts new file mode 100644 index 000000000..f93e99075 --- /dev/null +++ b/tests/prod-server-prerender-endpoints.test.ts @@ -0,0 +1,67 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { VINEXT_PRERENDER_SECRET_HEADER } from "../packages/vinext/src/server/headers.js"; + +describe("Pages production prerender endpoints", () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) { + fs.rmSync(root, { force: true, recursive: true }); + } + }); + + it("rejects incompatible page exports before a missing getStaticPaths returns null", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-prerender-endpoint-")); + roots.push(root); + const serverDir = path.join(root, "dist", "server"); + fs.mkdirSync(path.join(root, "dist", "client"), { recursive: true }); + fs.mkdirSync(serverDir, { recursive: true }); + fs.writeFileSync( + path.join(serverDir, "entry.js"), + [ + "export const vinextConfig = {};", + "export const pageRoutes = [{", + " pattern: '/posts/:id',", + " module: {", + " default: Object.assign(function Page() {}, { getInitialProps: async () => ({}) }),", + " getStaticProps: async () => ({ props: {} }),", + " },", + "}];", + "export async function renderPage() { return new Response('ok'); }", + "export async function handleApiRoute() { return new Response('api'); }", + "export async function runMiddleware() { return null; }", + "", + ].join("\n"), + ); + fs.writeFileSync( + path.join(serverDir, "vinext-server.json"), + JSON.stringify({ prerenderSecret: "test-prerender-secret" }), + ); + + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + const { server, port } = await startProdServer({ + host: "127.0.0.1", + noCompression: true, + outDir: path.join(root, "dist"), + port: 0, + silent: true, + }); + + try { + const response = await fetch( + `http://127.0.0.1:${port}/__vinext/prerender/pages-static-paths?pattern=/posts/:id`, + { headers: { [VINEXT_PRERENDER_SECRET_HEADER]: "test-prerender-secret" } }, + ); + + expect(response.status).toBe(500); + await expect(response.text()).resolves.toContain( + "You can not use getInitialProps with getStaticProps", + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +});