diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 50331f341..b2eef17b3 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 9dff17a6b..62e444a20 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 44abd688c..0db8e7976 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 1d37f7ca3..1099d6b8a 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");