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
38 changes: 23 additions & 15 deletions packages/cloudflare/src/cdn-warm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,15 +516,6 @@ function validateRscWarmResponse(
expectedBuildId?: string,
expectedRscBuildId?: string,
): WarmValidation {
if (response.redirected || response.status < 200 || response.status >= 300) {
return {
outcome: "failed",
error: response.redirected ? "redirected response" : `HTTP ${response.status}`,
};
}
if (!response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE)) {
return { outcome: "failed", error: `expected ${VINEXT_RSC_CONTENT_TYPE} response` };
}
const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
if (buildIdentityValidation) return buildIdentityValidation;
if (
Expand All @@ -536,6 +527,19 @@ function validateRscWarmResponse(
error: `response ${VINEXT_RSC_BUILD_ID_HEADER} does not match build ${expectedRscBuildId}`,
};
}
if (response.redirected) {
return { outcome: "failed", error: "redirected response" };
}
if (response.status < 200 || response.status >= 300) {
if (expectedBuildId !== undefined || expectedRscBuildId !== undefined) {
const cachePolicyValidation = validateCachePolicy(response, true);
if (cachePolicyValidation.outcome === "skipped") return cachePolicyValidation;
}
return { outcome: "failed", error: `HTTP ${response.status}` };
}
if (!response.headers.get("Content-Type")?.toLowerCase().startsWith(VINEXT_RSC_CONTENT_TYPE)) {
return { outcome: "failed", error: `expected ${VINEXT_RSC_CONTENT_TYPE} response` };
}
const cachePolicyValidation = validateCachePolicy(response, true);
if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation;
const vary = new Set(
Expand All @@ -556,14 +560,18 @@ function validateRscWarmResponse(
}

function validateHtmlWarmResponse(response: Response, expectedBuildId?: string): WarmValidation {
if (response.redirected || response.status < 200 || response.status >= 300) {
return {
outcome: "failed",
error: response.redirected ? "redirected response" : `HTTP ${response.status}`,
};
}
const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
if (buildIdentityValidation) return buildIdentityValidation;
if (response.redirected) {
return { outcome: "failed", error: "redirected response" };
}
if (response.status < 200 || response.status >= 300) {
if (expectedBuildId !== undefined) {
const cachePolicyValidation = validateCachePolicy(response, true);
if (cachePolicyValidation.outcome === "skipped") return cachePolicyValidation;
}
return { outcome: "failed", error: `HTTP ${response.status}` };
}
const cachePolicyValidation = validateCachePolicy(response, true);
if (cachePolicyValidation.outcome !== "warmed") return cachePolicyValidation;
const extraVary = (response.headers.get("Vary") ?? "")
Expand Down
42 changes: 36 additions & 6 deletions packages/vinext/src/build/prerender-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,37 @@ function throwDiscoveryFailure(route: string, error: unknown): never {
throw new Error(`Failed to discover warmup path(s) for ${route}: ${message}`, { cause: error });
}

function validatePagesStaticPathsResult(
value: unknown,
route: string,
): { fallback: boolean | "blocking"; paths: StaticPathsEntry[] } {
const expected = "Expected { paths: [], fallback: boolean | 'blocking' }.";
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`Invalid value returned from getStaticPaths for ${route}. ${expected}`);
}

const result = value as Record<string, unknown>;
const extraKeys = Object.keys(result).filter((key) => key !== "paths" && key !== "fallback");
if (extraKeys.length > 0) {
throw new Error(
`Extra key(s) returned from getStaticPaths for ${route}: ${extraKeys.join(", ")}. ${expected}`,
);
}
if (typeof result.fallback !== "boolean" && result.fallback !== "blocking") {
throw new Error(`Invalid fallback returned from getStaticPaths for ${route}. ${expected}`);
}
if (!Array.isArray(result.paths)) {
throw new Error(
`Invalid paths returned from getStaticPaths for ${route}; paths must be an array.`,
);
}

return {
fallback: result.fallback,
paths: result.paths as StaticPathsEntry[],
};
}

async function fetchDiscoveryEndpoint(
url: string,
headers: Record<string, string>,
Expand Down Expand Up @@ -269,13 +300,12 @@ async function collectPagesPaths(options: {
`${options.baseUrl}/__vinext/prerender/pages-static-paths?${search}`,
options.secretHeaders,
);
if (text === null) continue;
if (text === null) {
throw new Error(`Invalid value returned from getStaticPaths for ${route.pattern}.`);
}

const pathsResult = JSON.parse(text) as {
paths?: Array<StaticPathsEntry>;
fallback?: unknown;
};
for (const item of pathsResult.paths ?? []) {
const pathsResult = validatePagesStaticPathsResult(JSON.parse(text), route.pattern);
for (const item of pathsResult.paths) {
let itemToNormalize = item;
let locale = options.i18n?.defaultLocale;
if (options.i18n && typeof item === "string") {
Expand Down
52 changes: 52 additions & 0 deletions tests/cloudflare-cdn-warm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,58 @@ describe("Cloudflare CDN warmup", () => {
).resolves.toMatchObject({ warmed: 0, skipped: 2, failed: 0 });
});

it("skips same-build non-success responses that explicitly opt out of caching", async () => {
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const isRsc = new Headers(init?.headers).get("rsc") === "1";
return new Response(isRsc ? "flight not found" : "redirect", {
status: isRsc ? 404 : 307,
headers: {
"cache-control": "no-store",
"cf-cache-status": "BYPASS",
"content-type": isRsc ? "text/x-component" : "text/html",
[VINEXT_CDN_BUILD_ID_HEADER]: "build-a",
...(isRsc ? { [VINEXT_RSC_BUILD_ID_HEADER]: "rsc-build-a" } : {}),
},
});
});

await expect(
warmCdnCache({
expectedBuildId: "build-a",
expectedRscBuildId: "rsc-build-a",
fetchImpl: fetchImpl as typeof fetch,
paths: ["/redirect"],
rscPaths: ["/not-found"],
strict: true,
targetUrl: "https://app.example.com",
}),
).resolves.toMatchObject({ warmed: 0, skipped: 2, failed: 0 });
});

it("does not skip a non-success response from a different build", async () => {
const fetchImpl = vi.fn(
async () =>
new Response("redirect", {
status: 307,
headers: {
"cache-control": "no-store",
"cf-cache-status": "BYPASS",
[VINEXT_CDN_BUILD_ID_HEADER]: "old-build",
},
}),
);

await expect(
warmCdnCache({
expectedBuildId: "build-a",
fetchImpl: fetchImpl as typeof fetch,
paths: ["/redirect"],
strict: true,
targetUrl: "https://app.example.com",
}),
).rejects.toThrow(`response ${VINEXT_CDN_BUILD_ID_HEADER} does not match build build-a`);
});

it("requires CDN admission evidence for HTML responses", async () => {
const fetchImpl = vi.fn(async () => new Response("html"));

Expand Down
27 changes: 27 additions & 0 deletions tests/prerender-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,33 @@ describe("prerender path manifest", () => {
);
});

it.each([
["null", null, "Invalid value returned"],
["missing paths", { fallback: false }, "Invalid paths returned"],
["null paths", { fallback: false, paths: null }, "Invalid paths returned"],
["invalid fallback", { fallback: "yes", paths: [] }, "Invalid fallback"],
["extra key", { extra: true, fallback: false, paths: [] }, "Extra key(s)"],
])("fails path discovery for %s getStaticPaths results", async (_name, result, message) => {
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/posts/[slug].tsx",
[
"export function getStaticPaths() { return null; }",
"export function getStaticProps() { return { props: {}, 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) for /posts/:slug: ${message}`,
);
});

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