From 34d2ecc8804a67c37330c58a1aec9203bd36785e Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:38:00 +0100 Subject: [PATCH 01/13] fix(build): validate CDN warm path params --- packages/vinext/src/build/prerender-paths.ts | 104 ++++++++++++++++-- .../src/server/app-prerender-static-params.ts | 29 ++++- tests/app-prerender-static-params.test.ts | 19 ++++ tests/prerender-paths.test.ts | 53 +++++++++ 4 files changed, 193 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 50331f3415..b2eef17b34 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -137,6 +137,78 @@ function validatePagesStaticPathsResult( }; } +type DynamicPatternParam = { name: string; optional: boolean; repeat: boolean }; + +function getDynamicPatternParams(pattern: string): DynamicPatternParam[] { + return pattern + .split("/") + .filter((segment) => segment.startsWith(":")) + .map((segment) => ({ + name: segment.slice(1, segment.endsWith("+") || segment.endsWith("*") ? -1 : undefined), + optional: segment.endsWith("*"), + repeat: segment.endsWith("+") || segment.endsWith("*"), + })); +} + +function validateDiscoveredParams( + value: unknown, + pattern: string, + source: "generateStaticParams" | "getStaticPaths", +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${source} must return parameter objects for ${pattern}.`); + } + + const params = { ...(value as Record) }; + for (const { name, optional, repeat } of getDynamicPatternParams(pattern)) { + const hasValue = Object.prototype.hasOwnProperty.call(params, name); + let paramValue = params[name]; + if ( + optional && + hasValue && + (paramValue === null || paramValue === undefined || paramValue === false) + ) { + paramValue = []; + params[name] = paramValue; + } + const valid = repeat + ? Array.isArray(paramValue) && paramValue.every((entry) => typeof entry === "string") + : typeof paramValue === "string"; + if (!valid) { + throw new Error( + `Parameter ${name} from ${source} for ${pattern} must be ${repeat ? "an array of strings" : "a string"}.`, + ); + } + } + return params as Record; +} + +function validatePagesStaticPathsEntry(entry: StaticPathsEntry, pattern: string): StaticPathsEntry { + if (typeof entry === "string") { + if (entry.includes("?") || entry.includes("#")) { + throw new Error( + `The provided path \`${entry}\` from getStaticPaths does not match the route pattern \`${pattern}\`.`, + ); + } + return entry; + } + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry; + + const extraKeys = Object.keys(entry).filter((key) => key !== "params" && key !== "locale"); + if (extraKeys.length > 0) { + throw new Error( + `Additional key(s) returned from getStaticPaths for ${pattern}: ${extraKeys.join(", ")}.`, + ); + } + if (entry.locale !== undefined && typeof entry.locale !== "string") { + throw new Error(`Invalid locale returned from getStaticPaths for ${pattern}.`); + } + return { + ...entry, + params: validateDiscoveredParams(entry.params, pattern, "getStaticPaths"), + }; +} + async function fetchDiscoveryEndpoint( url: string, headers: Record, @@ -306,19 +378,25 @@ async function collectPagesPaths(options: { const pathsResult = validatePagesStaticPathsResult(JSON.parse(text), route.pattern); for (const item of pathsResult.paths) { - let itemToNormalize = item; + const validatedItem = validatePagesStaticPathsEntry(item, route.pattern); + let itemToNormalize = validatedItem; let locale = options.i18n?.defaultLocale; - if (options.i18n && typeof item === "string") { - const localeInfo = extractPagesStaticPathLocale(item, options.i18n); + if (options.i18n && typeof validatedItem === "string") { + const localeInfo = extractPagesStaticPathLocale(validatedItem, options.i18n); itemToNormalize = localeInfo.url; locale = localeInfo.locale; - } else if (options.i18n && item && typeof item === "object" && item.locale) { - if (!options.i18n.locales.includes(item.locale)) { + } else if ( + options.i18n && + validatedItem && + typeof validatedItem === "object" && + validatedItem.locale + ) { + if (!options.i18n.locales.includes(validatedItem.locale)) { throw new Error( - `Invalid locale returned from getStaticPaths for ${route.pattern}: ${item.locale}`, + `Invalid locale returned from getStaticPaths for ${route.pattern}: ${validatedItem.locale}`, ); } - locale = item.locale; + locale = validatedItem.locale; } const normalized = normalizeStaticPathsEntry(itemToNormalize, route.pattern); @@ -391,7 +469,17 @@ async function collectAppPaths(options: { options.secretHeaders, ); if (text === null) return null; - return JSON.parse(text) as Record[]; + const value = JSON.parse(text) as unknown; + if (!Array.isArray(value)) { + throw new Error(`generateStaticParams must return an array for ${pattern}.`); + } + return value.map((entry) => + validateDiscoveredParams( + { ...params, ...(entry as Record) }, + pattern, + "generateStaticParams", + ), + ); })(); void request.catch(() => staticParamsCache.delete(cacheKey)); staticParamsCache.set(cacheKey, request); diff --git a/packages/vinext/src/server/app-prerender-static-params.ts b/packages/vinext/src/server/app-prerender-static-params.ts index 9dff17a6b5..62e444a203 100644 --- a/packages/vinext/src/server/app-prerender-static-params.ts +++ b/packages/vinext/src/server/app-prerender-static-params.ts @@ -3,6 +3,15 @@ import { isUnknownRecord } from "../utils/record.js"; type GenerateStaticParamsFunction = (input: { params: RootParams }) => unknown; +const PRERENDER_PATH_DISCOVERY_ENV = "__VINEXT_PRERENDER_PATH_DISCOVERY"; + +function invalidGenerateStaticParamsResult(message: string): [] { + if (process.env[PRERENDER_PATH_DISCOVERY_ENV] === "1") { + throw new Error(message); + } + return []; +} + /** * A lazily-loaded `generateStaticParams` source. Page modules are code-split * out of the RSC entry (see `entries/app-rsc-manifest.ts`), so the @@ -103,9 +112,15 @@ export function createAppPrerenderStaticParamsResolver( const picked = filterRootParams(input.params); return runWithRootParamsScope(picked, async () => { const result = await single(input); - if (!Array.isArray(result)) return []; + if (!Array.isArray(result)) { + return invalidGenerateStaticParamsResult("generateStaticParams must return an array"); + } for (const item of result) { - if (!isRootParams(item)) return []; + if (!isRootParams(item)) { + return invalidGenerateStaticParamsResult( + "generateStaticParams must return an array of objects", + ); + } } return result; }); @@ -123,10 +138,16 @@ export function createAppPrerenderStaticParamsResolver( generateStaticParams({ params: parentParams }), ); - if (!Array.isArray(result)) return []; + if (!Array.isArray(result)) { + return invalidGenerateStaticParamsResult("generateStaticParams must return an array"); + } for (const item of result) { - if (!isRootParams(item)) return []; + if (!isRootParams(item)) { + return invalidGenerateStaticParamsResult( + "generateStaticParams must return an array of objects", + ); + } nextParamSets.push({ ...parentParams, ...item }); } } diff --git a/tests/app-prerender-static-params.test.ts b/tests/app-prerender-static-params.test.ts index 44abd688cc..0db8e7976c 100644 --- a/tests/app-prerender-static-params.test.ts +++ b/tests/app-prerender-static-params.test.ts @@ -68,4 +68,23 @@ describe("createAppPrerenderStaticParamsResolver", () => { { a: "2", b: "x" }, ]); }); + + it("surfaces malformed results during CDN warm path discovery", async () => { + const previous = process.env.__VINEXT_PRERENDER_PATH_DISCOVERY; + process.env.__VINEXT_PRERENDER_PATH_DISCOVERY = "1"; + try { + const nonArray = createAppPrerenderStaticParamsResolver([() => null]); + await expect(nonArray!({ params: {} })).rejects.toThrow( + "generateStaticParams must return an array", + ); + + const nonObjectEntry = createAppPrerenderStaticParamsResolver([() => ["slug"]]); + await expect(nonObjectEntry!({ params: {} })).rejects.toThrow( + "generateStaticParams must return an array of objects", + ); + } finally { + if (previous === undefined) delete process.env.__VINEXT_PRERENDER_PATH_DISCOVERY; + else process.env.__VINEXT_PRERENDER_PATH_DISCOVERY = previous; + } + }); }); diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 1d37f7ca3c..1099d6b8a0 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -763,6 +763,59 @@ describe("prerender path manifest", () => { ); }); + it.each([ + ["an extra entry key", "/posts/[slug].tsx", { extra: true, params: { slug: "x" } }], + ["a numeric dynamic param", "/posts/[slug].tsx", { params: { slug: 123 } }], + ["an array dynamic param", "/posts/[slug].tsx", { params: { slug: ["a", "b"] } }], + ["a scalar catch-all param", "/docs/[...parts].tsx", { params: { parts: "a" } }], + ["a query-bearing string path", "/posts/[slug].tsx", "/posts/query?x=1"], + ])("fails path discovery for getStaticPaths entry with %s", async (_name, file, entry) => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/entry.js", "export default {};\n"); + writeFile( + `pages${file}`, + [ + "export function getStaticPaths() { return { paths: [], fallback: false }; }", + "export function getStaticProps() { return { props: {}, revalidate: 60 }; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue(Response.json({ fallback: false, paths: [entry] })); + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + + await expect(emitPrerenderPathManifest({ root: tmpDir })).rejects.toThrow( + "Failed to discover warmup path(s)", + ); + }); + + it.each([ + ["a numeric dynamic param", "app/posts/[slug]/page.tsx", [{ slug: 123 }]], + ["a scalar catch-all param", "app/docs/[...parts]/page.tsx", [{ parts: "a" }]], + ["a missing optional catch-all", "app/docs/[[...parts]]/page.tsx", [{}]], + ])("fails App path discovery for generateStaticParams with %s", async (_name, file, result) => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + file, + [ + "export function generateStaticParams() { return []; }", + "export const revalidate = 60;", + "export default function Page() { return null; }", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue(Response.json(result)); + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + + await expect(emitPrerenderPathManifest({ root: tmpDir })).rejects.toThrow( + "Failed to discover warmup path(s)", + ); + }); + it("excludes only the locale-specific Pages key affected by a rewrite", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); From 414c6a12690bad9dee20ce8f6622ad2c183b1a6e Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:38:57 +0100 Subject: [PATCH 02/13] fix(build): exclude handlers from CDN warm paths --- packages/vinext/src/build/prerender-paths.ts | 50 +++++++++++++------- tests/prerender-paths.test.ts | 12 ++--- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index b2eef17b34..6695708f73 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -559,13 +559,13 @@ async function collectAppPaths(options: { return { loadingShellPaths, paths }; } -async function resolveAppRscWarmPaths(options: { +async function resolveAppWarmPaths(options: { appDir: string; i18n: ResolvedNextConfig["i18n"]; pagesDir: string | null; pageExtensions: readonly string[]; paths: readonly string[]; -}): Promise<{ loadingShellPaths: string[]; rscPaths: string[] }> { +}): Promise<{ htmlPaths: string[]; loadingShellPaths: string[]; rscPaths: string[] }> { const appRoutes = await appRouter(options.appDir, options.pageExtensions); const [pageRoutes, apiRoutes] = options.pagesDir ? await Promise.all([ @@ -575,6 +575,7 @@ async function resolveAppRscWarmPaths(options: { : [[], []]; const rscPaths: string[] = []; + const htmlPaths: string[] = []; const loadingShellPaths: string[] = []; for (const pathname of options.paths) { const appMatch = matchAppRoute(pathname, appRoutes); @@ -586,6 +587,12 @@ async function resolveAppRscWarmPaths(options: { const appRenderEntryPath = getAppRouteRenderEntryPath(matchedAppRoute); if (!appRenderEntryPath) continue; + if ( + classifyAppRoute(appRenderEntryPath, matchedAppRoute.routePath, matchedAppRoute.isDynamic) + .type === "api" + ) { + continue; + } // Pages Router i18n prefixes are routing metadata rather than part of the // filesystem route. Production strips them before matching Pages/API @@ -603,12 +610,13 @@ async function resolveAppRscWarmPaths(options: { continue; } + htmlPaths.push(pathname); rscPaths.push(pathname); if (appRouteHasMainTreeLoadingBoundary(matchedAppRoute)) { loadingShellPaths.push(pathname); } } - return { loadingShellPaths, rscPaths }; + return { htmlPaths, loadingShellPaths, rscPaths }; } function configuredRouteAffectsWarmPath( @@ -764,20 +772,26 @@ export async function emitPrerenderPathManifest( ? paths.filter((pathname) => configuredRouteAffectsWarmPath(pathname, config)) : [], ); - const warmPaths = paths.filter((pathname) => !excludedWarmPathSet.has(pathname)); - const appOwnedWarmPaths = - options.responseVary && appDir - ? await resolveAppRscWarmPaths({ - appDir, - i18n: config.i18n, - pagesDir, - pageExtensions: config.pageExtensions, - paths: discoveredAppPaths.filter((pathname) => !excludedWarmPathSet.has(pathname)), - }) - : { - loadingShellPaths: discoveredLoadingShellPaths, - rscPaths: discoveredAppPaths, - }; + const configuredPagesWarmPaths = discoveredPagesPaths.filter( + (pathname) => !excludedWarmPathSet.has(pathname), + ); + const appOwnedWarmPaths = appDir + ? await resolveAppWarmPaths({ + appDir, + i18n: config.i18n, + pagesDir, + pageExtensions: config.pageExtensions, + paths: discoveredAppPaths.filter((pathname) => !excludedWarmPathSet.has(pathname)), + }) + : { + htmlPaths: discoveredAppPaths, + loadingShellPaths: discoveredLoadingShellPaths, + rscPaths: discoveredAppPaths, + }; + const warmPathSet = new Set([...appOwnedWarmPaths.htmlPaths, ...configuredPagesWarmPaths]); + const warmPaths = paths.filter( + (pathname) => !excludedWarmPathSet.has(pathname) && warmPathSet.has(pathname), + ); const manifest: PrerenderPathManifest = { ...(config.basePath ? { basePath: config.basePath } : {}), @@ -786,7 +800,7 @@ export async function emitPrerenderPathManifest( ...(config.deploymentId ? { deploymentId: config.deploymentId } : {}), ...(pagesDir ? { - pagesPaths: discoveredPagesPaths.filter((pathname) => !excludedWarmPathSet.has(pathname)), + pagesPaths: configuredPagesWarmPaths, } : {}), ...(excludedWarmPathSet.size > 0 ? { excludedWarmPaths: Array.from(excludedWarmPathSet) } : {}), diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 1099d6b8a0..7ed1893a42 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -351,7 +351,7 @@ describe("prerender path manifest", () => { expect(manifest?.excludedWarmPaths).toEqual(["/foo"]); }); - it("excludes Pages-owned hybrid paths from App RSC warm discovery", async () => { + it("excludes Pages-owned hybrid paths from App warm discovery", async () => { // Next.js resolves matching Pages and App routes by cross-router specificity: // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-params/use-params.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/pages-to-app-routing/pages-to-app-routing.test.ts @@ -387,12 +387,7 @@ describe("prerender path manifest", () => { responseVary: "verbatim", }); - expect(manifest?.paths).toEqual([ - "/pages-dir/static", - "/pages-dir/foobar", - "/api/status", - "/specific/value", - ]); + expect(manifest?.paths).toEqual(["/pages-dir/static", "/specific/value"]); expect(manifest?.rscPaths).toEqual(["/pages-dir/static", "/specific/value"]); expect(manifest?.loadingShellPaths).toEqual(["/specific/value"]); expect(manifest?.pagesPaths).toEqual([]); @@ -412,6 +407,7 @@ describe("prerender path manifest", () => { ); writeFile("app/pages-dir/static/page.tsx", "export default function Page() { return null; }\n"); writeFile("app/specific/[id]/page.tsx", "export default function Page() { return null; }\n"); + writeFile("app/api/status/route.ts", "export function GET() { return new Response('ok'); }\n"); writeFile( "app/specific/[id]/loading.tsx", "export default function Loading() { return null; }\n", @@ -427,9 +423,9 @@ describe("prerender path manifest", () => { expect(manifest?.rscPaths).toEqual([ "/pages-dir/static", "/pages-dir/foobar", - "/api/status", "/specific/value", ]); + expect(manifest?.paths).toEqual(["/pages-dir/static", "/pages-dir/foobar", "/specific/value"]); expect(manifest?.loadingShellPaths).toEqual(["/specific/value"]); }); From 25e23c98d1acd7dc040d24f8a9db40a6ed8e3674 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:45:26 +0100 Subject: [PATCH 03/13] fix(build): resolve all CDN warm path owners --- packages/vinext/src/build/prerender-paths.ts | 43 ++++++++++---------- tests/prerender-paths.test.ts | 39 +++++++++++++++++- 2 files changed, 60 insertions(+), 22 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 6695708f73..13897ef2a7 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -579,21 +579,6 @@ async function resolveAppWarmPaths(options: { const loadingShellPaths: string[] = []; for (const pathname of options.paths) { const appMatch = matchAppRoute(pathname, appRoutes); - if (!appMatch) continue; - // The trie returns the exact object from appRoutes. Its public matcher type - // exposes the shared AppRoute fields, so recover the graph-owned metadata - // here without rescanning the route table for every concrete path. - const matchedAppRoute = appMatch.route as (typeof appRoutes)[number]; - - const appRenderEntryPath = getAppRouteRenderEntryPath(matchedAppRoute); - if (!appRenderEntryPath) continue; - if ( - classifyAppRoute(appRenderEntryPath, matchedAppRoute.routePath, matchedAppRoute.isDynamic) - .type === "api" - ) { - continue; - } - // Pages Router i18n prefixes are routing metadata rather than part of the // filesystem route. Production strips them before matching Pages/API // routes, while the App Router still matches the original pathname. @@ -606,7 +591,25 @@ async function resolveAppWarmPaths(options: { // rather than becoming a Pages API request after normalization. const isPagesApiRequest = pathname === "/api" || pathname.startsWith("/api/"); const pagesMatch = matchRoute(pagesPathname, isPagesApiRequest ? apiRoutes : pageRoutes); - if (pagesMatch && pagesRouteHasPriorityOverAppRoute(pagesMatch.route, matchedAppRoute)) { + if ( + pagesMatch && + (!appMatch || pagesRouteHasPriorityOverAppRoute(pagesMatch.route, appMatch.route)) + ) { + if (!isPagesApiRequest) htmlPaths.push(pathname); + continue; + } + if (!appMatch) continue; + + // The trie returns the exact object from appRoutes. Its public matcher type + // exposes the shared AppRoute fields, so recover the graph-owned metadata + // here without rescanning the route table for every concrete path. + const matchedAppRoute = appMatch.route as (typeof appRoutes)[number]; + const appRenderEntryPath = getAppRouteRenderEntryPath(matchedAppRoute); + if (!appRenderEntryPath) continue; + if ( + classifyAppRoute(appRenderEntryPath, matchedAppRoute.routePath, matchedAppRoute.isDynamic) + .type === "api" + ) { continue; } @@ -775,23 +778,21 @@ export async function emitPrerenderPathManifest( const configuredPagesWarmPaths = discoveredPagesPaths.filter( (pathname) => !excludedWarmPathSet.has(pathname), ); + const configuredCandidatePaths = paths.filter((pathname) => !excludedWarmPathSet.has(pathname)); const appOwnedWarmPaths = appDir ? await resolveAppWarmPaths({ appDir, i18n: config.i18n, pagesDir, pageExtensions: config.pageExtensions, - paths: discoveredAppPaths.filter((pathname) => !excludedWarmPathSet.has(pathname)), + paths: configuredCandidatePaths, }) : { htmlPaths: discoveredAppPaths, loadingShellPaths: discoveredLoadingShellPaths, rscPaths: discoveredAppPaths, }; - const warmPathSet = new Set([...appOwnedWarmPaths.htmlPaths, ...configuredPagesWarmPaths]); - const warmPaths = paths.filter( - (pathname) => !excludedWarmPathSet.has(pathname) && warmPathSet.has(pathname), - ); + const warmPaths = appDir ? appOwnedWarmPaths.htmlPaths : configuredPagesWarmPaths; const manifest: PrerenderPathManifest = { ...(config.basePath ? { basePath: config.basePath } : {}), diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 7ed1893a42..b1bc41e2ed 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -387,7 +387,7 @@ describe("prerender path manifest", () => { responseVary: "verbatim", }); - expect(manifest?.paths).toEqual(["/pages-dir/static", "/specific/value"]); + expect(manifest?.paths).toEqual(["/pages-dir/static", "/pages-dir/foobar", "/specific/value"]); expect(manifest?.rscPaths).toEqual(["/pages-dir/static", "/specific/value"]); expect(manifest?.loadingShellPaths).toEqual(["/specific/value"]); expect(manifest?.pagesPaths).toEqual([]); @@ -487,6 +487,43 @@ describe("prerender path manifest", () => { expect(manifest?.pagesPaths).toEqual(["/about", "/fr/about"]); }); + it("resolves Pages-discovered warm paths to their runtime App owner", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile("app/health/route.ts", "export function GET() { return new Response('ok'); }\n"); + writeFile("app/specific/[id]/page.tsx", "export default function Page() { return null; }\n"); + writeFile( + "pages/[...path].tsx", + [ + "export function getStaticPaths() { return { paths: [], fallback: false }; }", + "export function getStaticProps() { return { props: {}, revalidate: 60 }; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + vi.mocked(fetch).mockImplementation(async (input) => { + const rawUrl = + input instanceof URL ? input.href : typeof input === "string" ? input : input.url; + const url = new URL(rawUrl); + if (url.pathname === "/__vinext/prerender/pages-static-paths") { + return Response.json({ fallback: false, paths: ["/health", "/specific/value"] }); + } + return new Response("null", { headers: { "content-type": "application/json" } }); + }); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual(["/specific/value"]); + expect(manifest?.rscPaths).toEqual(["/specific/value"]); + expect(manifest?.pagesPaths).toEqual(["/health", "/specific/value"]); + }); + it("fails path discovery when generateStaticParams discovery aborts", async () => { vi.stubGlobal( "fetch", From 9d3d6ddd8867fd7513ffce7aeb0a309570a85840 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:46:04 +0100 Subject: [PATCH 04/13] fix(build): preserve exact CDN warm path identity --- packages/vinext/src/build/prerender-paths.ts | 34 ++++++++++++++----- .../src/server/app-prerender-static-params.ts | 4 ++- tests/app-prerender-static-params.test.ts | 5 +++ tests/prerender-paths.test.ts | 9 +++-- 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 13897ef2a7..f915611d74 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -185,7 +185,12 @@ function validateDiscoveredParams( function validatePagesStaticPathsEntry(entry: StaticPathsEntry, pattern: string): StaticPathsEntry { if (typeof entry === "string") { - if (entry.includes("?") || entry.includes("#")) { + if ( + !entry.startsWith("/") || + entry.includes("//") || + entry.includes("?") || + entry.includes("#") + ) { throw new Error( `The provided path \`${entry}\` from getStaticPaths does not match the route pattern \`${pattern}\`.`, ); @@ -381,10 +386,12 @@ async function collectPagesPaths(options: { const validatedItem = validatePagesStaticPathsEntry(item, route.pattern); let itemToNormalize = validatedItem; let locale = options.i18n?.defaultLocale; + let explicitLocalePrefix: string | undefined; if (options.i18n && typeof validatedItem === "string") { const localeInfo = extractPagesStaticPathLocale(validatedItem, options.i18n); itemToNormalize = localeInfo.url; locale = localeInfo.locale; + explicitLocalePrefix = localeInfo.explicitLocalePrefix; } else if ( options.i18n && validatedItem && @@ -404,7 +411,11 @@ async function collectPagesPaths(options: { throw new Error(normalized.error); } const pathname = buildUrlFromParams(route.pattern, normalized.params); - addPath(paths, seen, localizePagesPath(pathname, locale, options.i18n)); + addPath( + paths, + seen, + localizePagesPath(pathname, locale, options.i18n, explicitLocalePrefix), + ); } } catch (error) { throwDiscoveryFailure(route.pattern, error); @@ -418,7 +429,11 @@ function localizePagesPath( pathname: string, locale: string | undefined, i18n: ResolvedNextConfig["i18n"], + explicitLocalePrefix?: string, ): string { + if (explicitLocalePrefix) { + return pathname === "/" ? `/${explicitLocalePrefix}` : `/${explicitLocalePrefix}${pathname}`; + } if (!i18n || !locale || locale === i18n.defaultLocale) return pathname; return pathname === "/" ? `/${locale}` : `/${locale}${pathname}`; } @@ -426,7 +441,7 @@ function localizePagesPath( function extractPagesStaticPathLocale( url: string, i18n: NonNullable, -): { locale: string; url: string } { +): { explicitLocalePrefix?: string; locale: string; url: string } { const queryIndex = url.indexOf("?"); const pathname = queryIndex === -1 ? url : url.slice(0, queryIndex); const query = queryIndex === -1 ? "" : url.slice(queryIndex); @@ -437,7 +452,7 @@ function extractPagesStaticPathLocale( : undefined; if (!locale) return extractLocaleFromUrl(url, i18n); const rest = `/${parts.slice(1).join("/")}`; - return { locale, url: `${rest || "/"}${query}` }; + return { explicitLocalePrefix: parts[0], locale, url: `${rest || "/"}${query}` }; } async function collectAppPaths(options: { @@ -473,13 +488,16 @@ async function collectAppPaths(options: { if (!Array.isArray(value)) { throw new Error(`generateStaticParams must return an array for ${pattern}.`); } - return value.map((entry) => - validateDiscoveredParams( + return value.map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`generateStaticParams must return parameter objects for ${pattern}.`); + } + return validateDiscoveredParams( { ...params, ...(entry as Record) }, pattern, "generateStaticParams", - ), - ); + ); + }); })(); void request.catch(() => staticParamsCache.delete(cacheKey)); staticParamsCache.set(cacheKey, request); diff --git a/packages/vinext/src/server/app-prerender-static-params.ts b/packages/vinext/src/server/app-prerender-static-params.ts index 62e444a203..3cef23b808 100644 --- a/packages/vinext/src/server/app-prerender-static-params.ts +++ b/packages/vinext/src/server/app-prerender-static-params.ts @@ -35,7 +35,9 @@ function isLazyStaticParamsSource(value: unknown): value is LazyStaticParamsSour } function isRootParams(value: unknown): value is RootParams { - return isUnknownRecord(value); + if (!isUnknownRecord(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } /** diff --git a/tests/app-prerender-static-params.test.ts b/tests/app-prerender-static-params.test.ts index 0db8e7976c..66be2d5ce5 100644 --- a/tests/app-prerender-static-params.test.ts +++ b/tests/app-prerender-static-params.test.ts @@ -82,6 +82,11 @@ describe("createAppPrerenderStaticParamsResolver", () => { await expect(nonObjectEntry!({ params: {} })).rejects.toThrow( "generateStaticParams must return an array of objects", ); + + const nonPlainEntry = createAppPrerenderStaticParamsResolver([() => [new Date(0)]]); + await expect(nonPlainEntry!({ params: {} })).rejects.toThrow( + "generateStaticParams must return an array of objects", + ); } finally { if (previous === undefined) delete process.env.__VINEXT_PRERENDER_PATH_DISCOVERY; else process.env.__VINEXT_PRERENDER_PATH_DISCOVERY = previous; diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index b1bc41e2ed..5396313e56 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -699,6 +699,7 @@ describe("prerender path manifest", () => { { params: { slug: "bonjour" }, locale: "fr" }, "/fr/posts/string-fr", "/FR/posts/string-fr-upper", + "/en/posts/string-en-explicit", "/posts/string-en", ], }); @@ -729,7 +730,8 @@ describe("prerender path manifest", () => { "/posts/hello", "/fr/posts/bonjour", "/fr/posts/string-fr", - "/fr/posts/string-fr-upper", + "/FR/posts/string-fr-upper", + "/en/posts/string-en-explicit", "/posts/string-en", ]); expect(manifest?.pagesPaths).toEqual(manifest?.paths); @@ -743,7 +745,8 @@ describe("prerender path manifest", () => { "/docs/posts/hello/", "/docs/fr/posts/bonjour/", "/docs/fr/posts/string-fr/", - "/docs/fr/posts/string-fr-upper/", + "/docs/FR/posts/string-fr-upper/", + "/docs/en/posts/string-en-explicit/", "/docs/posts/string-en/", ]); }); @@ -802,6 +805,8 @@ describe("prerender path manifest", () => { ["an array dynamic param", "/posts/[slug].tsx", { params: { slug: ["a", "b"] } }], ["a scalar catch-all param", "/docs/[...parts].tsx", { params: { parts: "a" } }], ["a query-bearing string path", "/posts/[slug].tsx", "/posts/query?x=1"], + ["a relative string path", "/posts/[slug].tsx", "posts/x"], + ["a double-slash string path", "/posts/[slug].tsx", "/posts//x"], ])("fails path discovery for getStaticPaths entry with %s", async (_name, file, entry) => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); From e52b26e56e9f9a1b66937276349352f3c9f580dc Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:54:45 +0100 Subject: [PATCH 05/13] fix(build): preserve encoded Pages warm paths --- packages/vinext/src/build/prerender-paths.ts | 28 ++++++++++---------- tests/prerender-paths.test.ts | 6 +++++ 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index f915611d74..3cdd7c1fbd 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -12,7 +12,11 @@ import { matchAppRoute, } from "../routing/app-router.js"; import { apiRouter, matchRoute, pagesRouter } from "../routing/pages-router.js"; -import { normalizeStaticPathsEntry, type StaticPathsEntry } from "../routing/route-pattern.js"; +import { + normalizeStaticPathname, + normalizeStaticPathsEntry, + type StaticPathsEntry, +} from "../routing/route-pattern.js"; import { getAppRouteRenderEntryPath, classifyAppRoute, @@ -386,12 +390,9 @@ async function collectPagesPaths(options: { const validatedItem = validatePagesStaticPathsEntry(item, route.pattern); let itemToNormalize = validatedItem; let locale = options.i18n?.defaultLocale; - let explicitLocalePrefix: string | undefined; if (options.i18n && typeof validatedItem === "string") { const localeInfo = extractPagesStaticPathLocale(validatedItem, options.i18n); itemToNormalize = localeInfo.url; - locale = localeInfo.locale; - explicitLocalePrefix = localeInfo.explicitLocalePrefix; } else if ( options.i18n && validatedItem && @@ -410,12 +411,15 @@ async function collectPagesPaths(options: { if ("error" in normalized) { throw new Error(normalized.error); } - const pathname = buildUrlFromParams(route.pattern, normalized.params); - addPath( - paths, - seen, - localizePagesPath(pathname, locale, options.i18n, explicitLocalePrefix), - ); + const pathname = + typeof validatedItem === "string" + ? normalizeStaticPathname(validatedItem) + : localizePagesPath( + buildUrlFromParams(route.pattern, normalized.params), + locale, + options.i18n, + ); + addPath(paths, seen, pathname); } } catch (error) { throwDiscoveryFailure(route.pattern, error); @@ -429,11 +433,7 @@ function localizePagesPath( pathname: string, locale: string | undefined, i18n: ResolvedNextConfig["i18n"], - explicitLocalePrefix?: string, ): string { - if (explicitLocalePrefix) { - return pathname === "/" ? `/${explicitLocalePrefix}` : `/${explicitLocalePrefix}${pathname}`; - } if (!i18n || !locale || locale === i18n.defaultLocale) return pathname; return pathname === "/" ? `/${locale}` : `/${locale}${pathname}`; } diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 5396313e56..f34485bc0c 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -701,6 +701,8 @@ describe("prerender path manifest", () => { "/FR/posts/string-fr-upper", "/en/posts/string-en-explicit", "/posts/string-en", + "/posts/%7Euser/", + "/posts/a%2fb/", ], }); } @@ -733,6 +735,8 @@ describe("prerender path manifest", () => { "/FR/posts/string-fr-upper", "/en/posts/string-en-explicit", "/posts/string-en", + "/posts/%7Euser", + "/posts/a%2fb", ]); expect(manifest?.pagesPaths).toEqual(manifest?.paths); expect(fetch).toHaveBeenCalledWith( @@ -748,6 +752,8 @@ describe("prerender path manifest", () => { "/docs/FR/posts/string-fr-upper/", "/docs/en/posts/string-en-explicit/", "/docs/posts/string-en/", + "/docs/posts/%7Euser/", + "/docs/posts/a%2fb/", ]); }); From adcabc9a70f6413907b24bba2c3351d65abe3000 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:56:18 +0100 Subject: [PATCH 06/13] test(cloudflare): preserve encoded warm request URLs --- tests/cloudflare-cdn-warm.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index e8311dda3a..58d19bab3f 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -73,6 +73,10 @@ describe("Cloudflare CDN warmup", () => { expect(buildWarmupUrl("https://app.example.com", "/search?q=x").href).toBe( "https://app.example.com/search?q=x", ); + expect(buildWarmupUrl("https://app.example.com", "/posts/%7Euser").pathname).toBe( + "/posts/%7Euser", + ); + expect(buildWarmupUrl("https://app.example.com", "/posts/a%2fb").pathname).toBe("/posts/a%2fb"); }); it("reads only build-discovered paths and does not require local prerender output", () => { From 720603329e0b7ef06db9d84ce7813d506855d0e7 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 04:04:21 +0100 Subject: [PATCH 07/13] fix(build): reject unsafe Pages warm paths --- packages/vinext/src/build/prerender-paths.ts | 36 ++++++++++++++++++-- tests/prerender-paths.test.ts | 29 ++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 3cdd7c1fbd..eeb41981dd 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -199,6 +199,13 @@ function validatePagesStaticPathsEntry(entry: StaticPathsEntry, pattern: string) `The provided path \`${entry}\` from getStaticPaths does not match the route pattern \`${pattern}\`.`, ); } + try { + for (const segment of entry.split("/")) decodeURIComponent(segment); + } catch { + throw new Error( + `The provided path \`${entry}\` from getStaticPaths contains malformed percent-encoding.`, + ); + } return entry; } if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry; @@ -429,6 +436,22 @@ async function collectPagesPaths(options: { return paths; } +async function excludePagesApiWarmPaths(options: { + i18n: ResolvedNextConfig["i18n"]; + pagesDir: string; + pageExtensions: readonly string[]; + paths: readonly string[]; +}): Promise { + const apiRoutes = await apiRouter(options.pagesDir, options.pageExtensions); + return options.paths.filter((pathname) => { + const pagesPathname = options.i18n + ? extractLocaleFromUrl(pathname, options.i18n).url + : pathname; + if (pagesPathname !== "/api" && !pagesPathname.startsWith("/api/")) return true; + return matchRoute(pagesPathname, apiRoutes) === null; + }); +} + function localizePagesPath( pathname: string, locale: string | undefined, @@ -796,6 +819,15 @@ export async function emitPrerenderPathManifest( const configuredPagesWarmPaths = discoveredPagesPaths.filter( (pathname) => !excludedWarmPathSet.has(pathname), ); + const resolvedPagesWarmPaths = + !appDir && pagesDir + ? await excludePagesApiWarmPaths({ + i18n: config.i18n, + pagesDir, + pageExtensions: config.pageExtensions, + paths: configuredPagesWarmPaths, + }) + : configuredPagesWarmPaths; const configuredCandidatePaths = paths.filter((pathname) => !excludedWarmPathSet.has(pathname)); const appOwnedWarmPaths = appDir ? await resolveAppWarmPaths({ @@ -810,7 +842,7 @@ export async function emitPrerenderPathManifest( loadingShellPaths: discoveredLoadingShellPaths, rscPaths: discoveredAppPaths, }; - const warmPaths = appDir ? appOwnedWarmPaths.htmlPaths : configuredPagesWarmPaths; + const warmPaths = appDir ? appOwnedWarmPaths.htmlPaths : resolvedPagesWarmPaths; const manifest: PrerenderPathManifest = { ...(config.basePath ? { basePath: config.basePath } : {}), @@ -819,7 +851,7 @@ export async function emitPrerenderPathManifest( ...(config.deploymentId ? { deploymentId: config.deploymentId } : {}), ...(pagesDir ? { - pagesPaths: configuredPagesWarmPaths, + pagesPaths: resolvedPagesWarmPaths, } : {}), ...(excludedWarmPathSet.size > 0 ? { excludedWarmPaths: Array.from(excludedWarmPathSet) } : {}), diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index f34485bc0c..81cdc7bc48 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -671,6 +671,34 @@ describe("prerender path manifest", () => { expect(manifest?.pagesPaths).toEqual(["/pages-only"]); }); + it("excludes Pages API handlers from Pages-only concrete warm paths", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/entry.js", "export default {};\n"); + writeFile( + "pages/[...slug].tsx", + [ + "export function getStaticPaths() { return { paths: [], fallback: false }; }", + "export function getStaticProps() { return { props: {}, revalidate: 60 }; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile( + "pages/api/[...slug].ts", + "export default function handler(_request, response) { response.end('ok'); }\n", + ); + vi.mocked(fetch).mockResolvedValue( + Response.json({ fallback: false, paths: ["/page/foo", "/api/foo"] }), + ); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ root: tmpDir }); + + expect(manifest?.paths).toEqual(["/page/foo"]); + expect(manifest?.pagesPaths).toEqual(["/page/foo"]); + }); + it("discovers locale-specific Pages Router warmup keys", async () => { // Next.js passes i18n metadata to getStaticPaths and qualifies each // returned pathname with its selected locale: @@ -813,6 +841,7 @@ describe("prerender path manifest", () => { ["a query-bearing string path", "/posts/[slug].tsx", "/posts/query?x=1"], ["a relative string path", "/posts/[slug].tsx", "posts/x"], ["a double-slash string path", "/posts/[slug].tsx", "/posts//x"], + ["malformed percent-encoding", "/posts/[slug].tsx", "/posts/%ZZ/"], ])("fails path discovery for getStaticPaths entry with %s", async (_name, file, entry) => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); From a13125b46eb06613fb4466ab166e88a9db9f46ad Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 04:05:02 +0100 Subject: [PATCH 08/13] fix(router): match localized warm paths case-insensitively --- packages/vinext/src/server/pages-i18n.ts | 6 ++++-- tests/pages-i18n.test.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/server/pages-i18n.ts b/packages/vinext/src/server/pages-i18n.ts index e1af65d6d3..3544d2fc7e 100644 --- a/packages/vinext/src/server/pages-i18n.ts +++ b/packages/vinext/src/server/pages-i18n.ts @@ -113,8 +113,10 @@ export function extractLocaleFromUrl( const parts = pathname.split("/").filter(Boolean); const query = url.includes("?") ? url.slice(url.indexOf("?")) : ""; - if (parts.length > 0 && i18nConfig.locales.includes(parts[0])) { - const locale = parts[0]; + const locale = i18nConfig.locales.find( + (candidate) => candidate.toLowerCase() === parts[0]?.toLowerCase(), + ); + if (locale) { const rest = "/" + parts.slice(1).join("/"); return { locale, url: (rest || "/") + query, hadPrefix: true }; } diff --git a/tests/pages-i18n.test.ts b/tests/pages-i18n.test.ts index 6597aef6fe..3a8c38f286 100644 --- a/tests/pages-i18n.test.ts +++ b/tests/pages-i18n.test.ts @@ -72,6 +72,14 @@ describe("Pages i18n domain helpers", () => { expect(addLocalePrefix("/FR/about", "fr", "en")).toBe("/FR/about"); }); + it("routes locale prefixes case-insensitively using the configured locale", () => { + expect(resolvePagesI18nRequest("/FR/about", i18n)).toMatchObject({ + hadPrefix: true, + locale: "fr", + url: "/about", + }); + }); + it("does not let NEXT_LOCALE override the current domain default locale", () => { expect( getLocaleRedirect({ From 26b3b5e448acdb90b8ea66ca36e684cf1a256952 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 04:05:59 +0100 Subject: [PATCH 09/13] fix(cache): honor build identity adapter capability --- packages/cloudflare/src/deploy.ts | 1 + packages/vinext/src/build/prerender-paths.ts | 5 ++++- packages/vinext/src/cli.ts | 4 ++++ tests/prerender-paths.test.ts | 21 +++++++++++++++++++- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index a161f42406..46484d0d8e 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -1043,6 +1043,7 @@ export async function deploy(options: DeployOptions): Promise { await emitPrerenderPathManifest({ root: info.root, nextConfig, + buildIdentity: hasBuildIdentityHeader ? "response-header" : undefined, responseVary: hasStrictResponseVary ? "verbatim" : undefined, routeRootConfig: viteConfigMetadata.routeRootConfig, }); diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index eeb41981dd..03718ebc5b 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -72,6 +72,7 @@ type EmitPrerenderPathManifestOptions = { routeRootConfig?: VinextRouteRootConfig | null; pagesBundlePath?: string; rscBundlePath?: string; + buildIdentity?: CdnCacheAdapterCapabilities["buildIdentity"]; responseVary?: CdnCacheAdapterCapabilities["responseVary"]; }; @@ -847,7 +848,9 @@ export async function emitPrerenderPathManifest( const manifest: PrerenderPathManifest = { ...(config.basePath ? { basePath: config.basePath } : {}), buildId: config.buildId, - ...(rscBuildId ? { buildIdentity: rscBuildId } : {}), + ...(rscBuildId && options.buildIdentity === "response-header" + ? { buildIdentity: rscBuildId } + : {}), ...(config.deploymentId ? { deploymentId: config.deploymentId } : {}), ...(pagesDir ? { diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 61d2a4e896..831f826266 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -64,6 +64,7 @@ import { } from "./config/prerender.js"; import { findVinextCacheConfigInPlugins, + hasBuildIdentityResponseHeader, hasVerbatimResponseVary, type VinextCacheConfig, } from "./cache/cache-adapters-virtual.js"; @@ -729,6 +730,9 @@ async function buildApp() { await emitPrerenderPathManifest({ root, nextConfig: resolvedNextConfig, + buildIdentity: hasBuildIdentityResponseHeader(buildConfigMetadata.cacheConfig) + ? "response-header" + : undefined, responseVary: hasVerbatimResponseVary(buildConfigMetadata.cacheConfig) ? "verbatim" : undefined, diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 81cdc7bc48..69e879fe14 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -101,7 +101,11 @@ describe("prerender path manifest", () => { const { emitPrerenderPathManifest } = await import("../packages/vinext/src/build/prerender-paths.js"); - const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" }); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + buildIdentity: "response-header", + responseVary: "verbatim", + }); expect(manifest).toEqual({ buildId: "build-a", @@ -289,6 +293,21 @@ describe("prerender path manifest", () => { ); }); + it("only advertises HTML build identity when the CDN adapter guarantees it", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile("app/page.tsx", "export default function Page() { return null; }\n"); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ root: tmpDir, responseVary: "verbatim" }); + + expect(manifest?.buildIdentity).toBeUndefined(); + expect(manifest?.rscBuildId).toBe("rsc-build-a"); + }); + it("matches rewrites against the trailing-slash warm URL", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); From 30569e53f44111bc568b84f85f02b903d4f0d42c Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 04:14:45 +0100 Subject: [PATCH 10/13] fix(build): reject normalized CDN warm paths --- packages/vinext/src/build/prerender-paths.ts | 25 ++++++++++++++++++-- tests/prerender-paths.test.ts | 4 ++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 03718ebc5b..16b2353e5d 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -144,6 +144,14 @@ function validatePagesStaticPathsResult( type DynamicPatternParam = { name: string; optional: boolean; repeat: boolean }; +function hasUnsafeRawUrlPathCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code === 92 || code <= 31 || code === 127) return true; + } + return false; +} + function getDynamicPatternParams(pattern: string): DynamicPatternParam[] { return pattern .split("/") @@ -184,6 +192,12 @@ function validateDiscoveredParams( `Parameter ${name} from ${source} for ${pattern} must be ${repeat ? "an array of strings" : "a string"}.`, ); } + const values = Array.isArray(paramValue) ? paramValue : [paramValue]; + if (values.some((entry) => entry === "." || entry === "..")) { + throw new Error( + `Parameter ${name} from ${source} for ${pattern} must not contain dot path segments.`, + ); + } } return params as Record; } @@ -194,19 +208,26 @@ function validatePagesStaticPathsEntry(entry: StaticPathsEntry, pattern: string) !entry.startsWith("/") || entry.includes("//") || entry.includes("?") || - entry.includes("#") + entry.includes("#") || + hasUnsafeRawUrlPathCharacter(entry) ) { throw new Error( `The provided path \`${entry}\` from getStaticPaths does not match the route pattern \`${pattern}\`.`, ); } + let decodedSegments: string[]; try { - for (const segment of entry.split("/")) decodeURIComponent(segment); + decodedSegments = entry.split("/").map((segment) => decodeURIComponent(segment)); } catch { throw new Error( `The provided path \`${entry}\` from getStaticPaths contains malformed percent-encoding.`, ); } + if (decodedSegments.some((segment) => segment === "." || segment === "..")) { + throw new Error( + `The provided path \`${entry}\` from getStaticPaths contains a dot path segment.`, + ); + } return entry; } if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry; diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 69e879fe14..cb784c89be 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -856,10 +856,13 @@ describe("prerender path manifest", () => { ["an extra entry key", "/posts/[slug].tsx", { extra: true, params: { slug: "x" } }], ["a numeric dynamic param", "/posts/[slug].tsx", { params: { slug: 123 } }], ["an array dynamic param", "/posts/[slug].tsx", { params: { slug: ["a", "b"] } }], + ["a dot-segment dynamic param", "/posts/[slug].tsx", { params: { slug: "." } }], ["a scalar catch-all param", "/docs/[...parts].tsx", { params: { parts: "a" } }], ["a query-bearing string path", "/posts/[slug].tsx", "/posts/query?x=1"], ["a relative string path", "/posts/[slug].tsx", "posts/x"], ["a double-slash string path", "/posts/[slug].tsx", "/posts//x"], + ["a raw backslash string path", "/posts/[slug].tsx", "/posts\\admin"], + ["an encoded dot-segment string path", "/posts/[...slug].tsx", "/posts/%2E%2E/admin"], ["malformed percent-encoding", "/posts/[slug].tsx", "/posts/%ZZ/"], ])("fails path discovery for getStaticPaths entry with %s", async (_name, file, entry) => { writeFile("package.json", JSON.stringify({ type: "module" })); @@ -884,6 +887,7 @@ describe("prerender path manifest", () => { it.each([ ["a numeric dynamic param", "app/posts/[slug]/page.tsx", [{ slug: 123 }]], + ["a dot-segment dynamic param", "app/posts/[slug]/page.tsx", [{ slug: ".." }]], ["a scalar catch-all param", "app/docs/[...parts]/page.tsx", [{ parts: "a" }]], ["a missing optional catch-all", "app/docs/[[...parts]]/page.tsx", [{}]], ])("fails App path discovery for generateStaticParams with %s", async (_name, file, result) => { From 16a2dbdf2aa3a2f25148d0de9d83aa05606566e0 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 04:17:13 +0100 Subject: [PATCH 11/13] fix(build): discover dynamic MDX warm paths --- packages/vinext/src/build/prerender-paths.ts | 39 ++--------- packages/vinext/src/build/prerender.ts | 4 +- .../src/server/app-prerender-endpoints.ts | 5 +- packages/vinext/src/server/prod-server.ts | 4 +- tests/app-prerender-endpoints.test.ts | 14 ++-- tests/prerender-paths.test.ts | 64 ++++++++++++++++++- 6 files changed, 78 insertions(+), 52 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 16b2353e5d..407f736e3f 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -17,12 +17,7 @@ import { normalizeStaticPathsEntry, type StaticPathsEntry, } from "../routing/route-pattern.js"; -import { - getAppRouteRenderEntryPath, - classifyAppRoute, - classifyPagesRoute, - hasNamedExport, -} from "./report.js"; +import { getAppRouteRenderEntryPath, classifyAppRoute, classifyPagesRoute } from "./report.js"; import { buildUrlFromParams, resolveParentParams, type StaticParamsMap } from "./prerender.js"; import { readPrerenderSecret } from "./server-manifest.js"; import { startProdServer } from "../server/prod-server.js"; @@ -260,7 +255,7 @@ async function fetchDiscoveryEndpoint( }); const text = await res.text(); if (!res.ok) throw new Error(`path discovery returned HTTP ${res.status}`); - if (text === "null") return null; + if (res.status === 204) return null; return text; } catch (error) { if (error instanceof Error && error.name === "AbortError") { @@ -272,15 +267,6 @@ async function fetchDiscoveryEndpoint( } } -function fileHasNamedExport(filePath: string | null | undefined, name: string): boolean { - if (!filePath) return false; - try { - return hasNamedExport(fs.readFileSync(filePath, "utf-8"), name); - } catch { - return false; - } -} - function resolveConfiguredRouteDirs( root: string, routeRootConfig: VinextRouteRootConfig | null | undefined, @@ -317,11 +303,6 @@ function resolveConfiguredRouteDirs( }; } -function appRouteMayHaveGenerateStaticParams(route: Awaited>[number]) { - if (fileHasNamedExport(route.pagePath, "generateStaticParams")) return true; - return route.layouts.some((layoutPath) => fileHasNamedExport(layoutPath, "generateStaticParams")); -} - async function shouldStartPathDiscoveryServer(options: { appDir: string | null; pagesDir: string | null; @@ -329,20 +310,12 @@ async function shouldStartPathDiscoveryServer(options: { }): Promise { if (options.appDir) { const routes = await appRouter(options.appDir, options.pageExtensions); - if (routes.some((route) => route.isDynamic && appRouteMayHaveGenerateStaticParams(route))) { - return true; - } + if (routes.some((route) => route.isDynamic)) return true; } if (options.pagesDir) { const routes = await pagesRouter(options.pagesDir, options.pageExtensions); - if ( - routes.some( - (route) => route.isDynamic && fileHasNamedExport(route.filePath, "getStaticPaths"), - ) - ) { - return true; - } + if (routes.some((route) => route.isDynamic)) return true; } return false; @@ -397,7 +370,6 @@ async function collectPagesPaths(options: { continue; } - if (!fileHasNamedExport(route.filePath, "getStaticPaths")) continue; if (!options.baseUrl) continue; try { @@ -411,7 +383,7 @@ async function collectPagesPaths(options: { options.secretHeaders, ); if (text === null) { - throw new Error(`Invalid value returned from getStaticPaths for ${route.pattern}.`); + continue; } const pathsResult = validatePagesStaticPathsResult(JSON.parse(text), route.pattern); @@ -574,7 +546,6 @@ async function collectAppPaths(options: { continue; } - if (!appRouteMayHaveGenerateStaticParams(route)) continue; try { const generateStaticParams = staticParamsMap[route.pattern]; if (typeof generateStaticParams !== "function") continue; diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts index 462643856d..19a4c14264 100644 --- a/packages/vinext/src/build/prerender.ts +++ b/packages/vinext/src/build/prerender.ts @@ -755,7 +755,7 @@ export async function prerenderPages({ ); return { paths: [], fallback: false }; } - if (text === "null") return { paths: [], fallback: false }; + if (res.status === 204 || text === "null") return { paths: [], fallback: false }; return JSON.parse(text) as { paths: Array; fallback: unknown; @@ -1183,7 +1183,7 @@ export async function prerenderApp({ ); return null; } - if (text === "null") return null; + if (res.status === 204 || text === "null") return null; return JSON.parse(text) as Record[]; })(); // Only cache on success — a rejected or error promise must not poison diff --git a/packages/vinext/src/server/app-prerender-endpoints.ts b/packages/vinext/src/server/app-prerender-endpoints.ts index e60ee40ad4..b7bd72ce3a 100644 --- a/packages/vinext/src/server/app-prerender-endpoints.ts +++ b/packages/vinext/src/server/app-prerender-endpoints.ts @@ -137,10 +137,7 @@ function jsonResponse(body: unknown, status = 200): Response { } function jsonNullResponse(): Response { - return new Response("null", { - headers: JSON_HEADERS, - status: 200, - }); + return new Response(null, { status: 204 }); } function parseParentParams(raw: string | null): RootParams { diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 073abde36d..7a15ad021c 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -2027,8 +2027,8 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { const route = pageRoutes?.find((r) => r.pattern === pattern); const fn = route?.module?.getStaticPaths; if (typeof fn !== "function") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end("null"); + res.writeHead(204); + res.end(); return; } try { diff --git a/tests/app-prerender-endpoints.test.ts b/tests/app-prerender-endpoints.test.ts index f6d45d5dd4..07c0636a80 100644 --- a/tests/app-prerender-endpoints.test.ts +++ b/tests/app-prerender-endpoints.test.ts @@ -248,13 +248,13 @@ describe("App prerender endpoint helpers", () => { }, ); - expect(staticParamsResponse?.status).toBe(200); - await expect(staticParamsResponse?.text()).resolves.toBe("null"); - expect(pagesResponse?.status).toBe(200); - await expect(pagesResponse?.text()).resolves.toBe("null"); + expect(staticParamsResponse?.status).toBe(204); + await expect(staticParamsResponse?.text()).resolves.toBe(""); + expect(pagesResponse?.status).toBe(204); + await expect(pagesResponse?.text()).resolves.toBe(""); }); - it("returns JSON null when the Pages Router loader returns a non-route shape", async () => { + it("returns no content when the Pages Router loader returns a non-route shape", async () => { const response = await handleAppPrerenderEndpoint( new Request("http://localhost/__vinext/prerender/pages-static-paths?pattern=/missing"), { @@ -265,8 +265,8 @@ describe("App prerender endpoint helpers", () => { }, ); - expect(response?.status).toBe(200); - await expect(response?.text()).resolves.toBe("null"); + expect(response?.status).toBe(204); + await expect(response?.text()).resolves.toBe(""); }); it("returns explicit endpoint errors for missing query fields and thrown user functions", async () => { diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index cb784c89be..3f6475f033 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -67,7 +67,7 @@ describe("prerender path manifest", () => { ) { return Response.json([{ category: "news" }]); } - return new Response("null", { headers: { "content-type": "application/json" } }); + return new Response(null, { status: 204 }); }), ); }); @@ -293,6 +293,64 @@ describe("prerender path manifest", () => { ); }); + it("discovers dynamic App MDX paths from the built runtime", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/[slug]/page.mdx", + 'export function generateStaticParams() { return [{ slug: "hello" }] }\n\n# Hello\n', + ); + vi.mocked(fetch).mockResolvedValue(Response.json([{ slug: "hello" }])); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const nextConfig = await resolveNextConfig( + { pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] }, + tmpDir, + ); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + nextConfig, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual(["/hello"]); + expect(manifest?.rscPaths).toEqual(["/hello"]); + }); + + it("discovers dynamic Pages MDX paths from the built runtime", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/entry.js", "export default {};\n"); + writeFile( + "pages/[slug].mdx", + [ + 'export function getStaticPaths() { return { paths: ["/hello"], fallback: false } }', + "export function getStaticProps() { return { props: {}, revalidate: 60 } }", + "", + "# Hello", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue(Response.json({ fallback: false, paths: ["/hello"] })); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const nextConfig = await resolveNextConfig( + { pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] }, + tmpDir, + ); + const manifest = await emitPrerenderPathManifest({ root: tmpDir, nextConfig }); + + expect(manifest?.paths).toEqual(["/hello"]); + expect(manifest?.pagesPaths).toEqual(["/hello"]); + }); + it("only advertises HTML build identity when the CDN adapter guarantees it", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); @@ -528,7 +586,7 @@ describe("prerender path manifest", () => { if (url.pathname === "/__vinext/prerender/pages-static-paths") { return Response.json({ fallback: false, paths: ["/health", "/specific/value"] }); } - return new Response("null", { headers: { "content-type": "application/json" } }); + return new Response(null, { status: 204 }); }); const { emitPrerenderPathManifest } = @@ -753,7 +811,7 @@ describe("prerender path manifest", () => { ], }); } - return new Response("null", { headers: { "content-type": "application/json" } }); + return new Response(null, { status: 204 }); }); const [{ emitPrerenderPathManifest }, { resolveNextConfig }, { readPrerenderWarmPlan }] = From 3e015c8047db4c6c13b2588d673946ef59655054 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 04:20:30 +0100 Subject: [PATCH 12/13] fix(build): skip routes without static param generators --- .../vinext/src/server/app-prerender-endpoints.ts | 1 + tests/app-prerender-endpoints.test.ts | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/app-prerender-endpoints.ts b/packages/vinext/src/server/app-prerender-endpoints.ts index b7bd72ce3a..c626393d2a 100644 --- a/packages/vinext/src/server/app-prerender-endpoints.ts +++ b/packages/vinext/src/server/app-prerender-endpoints.ts @@ -90,6 +90,7 @@ async function handleStaticParamsEndpoint( pattern, rootParamNamesByPattern: options.rootParamNamesByPattern ?? {}, }); + if (result === null) return jsonNullResponse(); return jsonResponse(result); } catch (error) { return jsonResponse({ error: String(error) }, 500); diff --git a/tests/app-prerender-endpoints.test.ts b/tests/app-prerender-endpoints.test.ts index 07c0636a80..9f104dbc02 100644 --- a/tests/app-prerender-endpoints.test.ts +++ b/tests/app-prerender-endpoints.test.ts @@ -229,7 +229,7 @@ describe("App prerender endpoint helpers", () => { }); }); - it("returns JSON null when the requested prerender function is absent", async () => { + 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"), { @@ -254,6 +254,20 @@ describe("App prerender endpoint helpers", () => { await expect(pagesResponse?.text()).resolves.toBe(""); }); + it("returns no content when a lazy App route resolves without a generator", async () => { + const response = await handleAppPrerenderEndpoint( + new Request("http://localhost/__vinext/prerender/static-params?pattern=/dynamic/:slug"), + { + isPrerenderEnabled: () => true, + pathname: "/__vinext/prerender/static-params", + staticParamsMap: { "/dynamic/:slug": async () => null }, + }, + ); + + expect(response?.status).toBe(204); + await expect(response?.text()).resolves.toBe(""); + }); + it("returns no content when the Pages Router loader returns a non-route shape", async () => { const response = await handleAppPrerenderEndpoint( new Request("http://localhost/__vinext/prerender/pages-static-paths?pattern=/missing"), From 59d7cb25e01cd582220957a2206f52cbf376887b Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 04:18:09 +0100 Subject: [PATCH 13/13] fix(cloudflare): validate warmup promotion delay --- packages/cloudflare/src/deploy.ts | 24 ++++++++++++++++++++++-- tests/cloudflare-cdn-warm-deploy.test.ts | 11 +++++++++++ tests/deploy.test.ts | 3 +++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 46484d0d8e..2304e1ad3f 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -154,6 +154,20 @@ function parseNonNegativeIntegerArg(raw: string, flag: string): number { return parsed; } +const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647; + +function validatePromotionDelay(value: number, raw = String(value)): number { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`--warm-cdn-promotion-delay expects a non-negative integer, but got "${raw}".`); + } + if (value > MAX_NODE_TIMER_DELAY_MS) { + throw new Error( + `--warm-cdn-promotion-delay must not exceed ${MAX_NODE_TIMER_DELAY_MS} milliseconds, but got "${raw}".`, + ); + } + return value; +} + function formatUnknownError(error: unknown): string { if (error instanceof Error && error.message) return error.message; return String(error); @@ -230,9 +244,12 @@ export function parseDeployArgs(args: string[]) { warmCdnPromotionDelay: values["warm-cdn-promotion-delay"] === undefined ? undefined - : parseNonNegativeIntegerArg( + : validatePromotionDelay( + parseNonNegativeIntegerArg( + values["warm-cdn-promotion-delay"], + "--warm-cdn-promotion-delay", + ), values["warm-cdn-promotion-delay"], - "--warm-cdn-promotion-delay", ), warmCdnIncludeFallbacks: values["warm-cdn-include-fallbacks"], experimentalTPR: values["experimental-tpr"], @@ -586,6 +603,9 @@ export async function deployWithCdnWarmup( "deploymentId" | "expectedBuildId" | "expectedRscBuildId" | "loadingShellPaths" | "rscPaths" >, ): Promise { + if (options.warmCdnPromotionDelay !== undefined) { + validatePromotionDelay(options.warmCdnPromotionDelay); + } if (options.warmCdnStrict && paths.length > 0 && options.expectedBuildId === undefined) { throw new Error( "Strict CDN HTML warmup requires a CDN adapter that declares build-identity response headers. " + diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 14089861c0..b92b495eeb 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -100,6 +100,17 @@ describe("Cloudflare CDN warmup deploy flow", () => { expect(hasCdnWarmRequests({ loadingShellPaths: [], paths: [], rscPaths: [] })).toBe(false); }); + it("rejects promotion delays that Node timers cannot represent before deploying", async () => { + const { deployWithCdnWarmup } = await import("../packages/cloudflare/src/deploy.js"); + + await expect( + deployWithCdnWarmup(tmpDir, [], { warmCdnPromotionDelay: 2_147_483_648 }), + ).rejects.toThrow( + '--warm-cdn-promotion-delay must not exceed 2147483647 milliseconds, but got "2147483648".', + ); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); + it("does not promote when deployment status cannot be read", async () => { writeFile("wrangler.jsonc", JSON.stringify({ name: "my-worker" })); execFileSyncMock.mockImplementation((_file: string, args: string[]) => { diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 10d72749a1..34489f3c6d 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -827,6 +827,9 @@ describe("parseDeployArgs", () => { expect(() => parseDeployArgs(["--warm-cdn-promotion-delay=-1"])).toThrow( '--warm-cdn-promotion-delay expects a non-negative integer, but got "-1".', ); + expect(() => parseDeployArgs(["--warm-cdn-promotion-delay=2147483648"])).toThrow( + '--warm-cdn-promotion-delay must not exceed 2147483647 milliseconds, but got "2147483648".', + ); }); it("trims whitespace from --env value", () => {