Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 96 additions & 8 deletions packages/vinext/src/build/prerender-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | string[]> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${source} must return parameter objects for ${pattern}.`);
}

const params = { ...(value as Record<string, unknown>) };
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<string, string | string[]>;
}

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<string, string>,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -391,7 +469,17 @@ async function collectAppPaths(options: {
options.secretHeaders,
);
if (text === null) return null;
return JSON.parse(text) as Record<string, string | string[]>[];
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<string, unknown>) },
pattern,
"generateStaticParams",
),
);
})();
void request.catch(() => staticParamsCache.delete(cacheKey));
staticParamsCache.set(cacheKey, request);
Expand Down
29 changes: 25 additions & 4 deletions packages/vinext/src/server/app-prerender-static-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
});
Expand All @@ -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 });
}
}
Expand Down
19 changes: 19 additions & 0 deletions tests/app-prerender-static-params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
});
53 changes: 53 additions & 0 deletions tests/prerender-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading