Files
cloudflare__vinext/tests/cacheability-admission.test.ts

498 lines
17 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import {
captureCacheabilityAdmissionBody,
createCacheabilityAdmissionCaptureBudget,
createWorkerCacheabilityAdmissionContext,
finalizeWorkerCacheabilityResponse,
} from "../packages/vinext/src/server/cacheability-request.js";
import {
CACHEABILITY_REQUEST_STATE,
type RouteCacheabilityState,
} from "../packages/vinext/src/shims/cacheability-classification.js";
import {
cacheabilityManifestRouteKey,
type CacheabilityManifestRoute,
} from "../packages/vinext/src/server/cacheability-manifest.js";
const encoder = new TextEncoder();
describe("cacheability admission capture", () => {
it("preserves all bytes when the bounded capture limit is exceeded", async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("first"));
controller.enqueue(encoder.encode("second"));
controller.close();
},
});
const captured = await captureCacheabilityAdmissionBody(body, Date.now() + 1_000, 5);
expect(captured.kind).toBe("fallback");
await expect(new Response(captured.body).text()).resolves.toBe("firstsecond");
});
it("falls back to the private stream without cancelling a slow response", async () => {
const body = new ReadableStream<Uint8Array>({
async start(controller) {
await new Promise((resolve) => setTimeout(resolve, 20));
controller.enqueue(encoder.encode("slow"));
controller.close();
},
});
const captured = await captureCacheabilityAdmissionBody(body, Date.now() + 5);
expect(captured.kind).toBe("fallback");
await expect(new Response(captured.body).text()).resolves.toBe("slow");
});
it("bounds completed captures across concurrent isolate requests", async () => {
const budget = createCacheabilityAdmissionCaptureBudget(5);
const first = await captureCacheabilityAdmissionBody(
new Response("first").body,
Date.now() + 1_000,
10,
budget,
);
expect(first.kind).toBe("captured");
expect(budget.reservedBytes).toBe(5);
const second = await captureCacheabilityAdmissionBody(
new Response("second").body,
Date.now() + 1_000,
10,
budget,
);
expect(second.kind).toBe("fallback");
await expect(new Response(second.body).text()).resolves.toBe("second");
expect(budget.reservedBytes).toBe(5);
await expect(new Response(first.body).text()).resolves.toBe("first");
expect(budget.reservedBytes).toBe(0);
});
it("releases the isolate reservation when a captured response is cancelled", async () => {
const budget = createCacheabilityAdmissionCaptureBudget(5);
const captured = await captureCacheabilityAdmissionBody(
new Response("first").body,
Date.now() + 1_000,
10,
budget,
);
expect(captured.kind).toBe("captured");
expect(budget.reservedBytes).toBe(5);
await captured.body?.cancel();
expect(budget.reservedBytes).toBe(0);
});
});
function cacheabilityState(context: object): RouteCacheabilityState {
return Reflect.get(context, CACHEABILITY_REQUEST_STATE) as RouteCacheabilityState;
}
function staticManifestRoute(): { raw: string; route: CacheabilityManifestRoute } {
const route: CacheabilityManifestRoute = {
kind: "app-page",
pattern: "/page",
representation: "html",
requestKey: "/page",
state: "static-candidate",
status: 200,
};
const key = cacheabilityManifestRouteKey(
route.kind,
route.pattern,
route.representation,
route.requestKey,
);
return {
raw: JSON.stringify({ buildId: "build-a", routes: { [key]: route }, version: 1 }),
route,
};
}
describe("single-request cacheability admission", () => {
const request = new Request("https://example.com/page", {
headers: { Accept: "text/html" },
});
it("admits a completed static response without a build manifest", async () => {
const base = { waitUntil() {} };
const context = createWorkerCacheabilityAdmissionContext(base, request, null, "build-a", true);
expect(context).not.toBe(base);
const state = cacheabilityState(context);
state.route = { kind: "app-page", pattern: "/page" };
state.outcome = {
cacheable: true,
cacheControl: "s-maxage=60, stale-while-revalidate=540",
};
state.frameworkResponseCachePolicy = { "cache-control": "no-store" };
const response = await finalizeWorkerCacheabilityResponse(
new Response("static", { headers: { "Cache-Control": "no-store" } }),
context,
);
expect(response.headers.get("Cache-Control")).toBe("s-maxage=60, stale-while-revalidate=540");
await expect(response.text()).resolves.toBe("static");
});
it("honors a final public App Page cache policy", async () => {
// Ported from Next.js:
// test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/custom-cache-control/custom-cache-control.test.ts
const context = createWorkerCacheabilityAdmissionContext(
{ waitUntil() {} },
request,
null,
"build-a",
true,
);
const state = cacheabilityState(context);
state.route = { kind: "app-page", pattern: "/page" };
state.outcome = {
cacheable: true,
cacheControl: "s-maxage=120, stale-while-revalidate=31535880",
};
state.frameworkResponseCachePolicy = { "cache-control": "no-store" };
const response = await finalizeWorkerCacheabilityResponse(
new Response("static", { headers: { "Cache-Control": "s-maxage=30" } }),
context,
);
expect(response.headers.get("Cache-Control")).toBe("s-maxage=30");
await expect(response.text()).resolves.toBe("static");
});
it("keeps a completed dynamic response private without a build manifest", async () => {
const context = createWorkerCacheabilityAdmissionContext(
{ waitUntil() {} },
request,
null,
"build-a",
true,
);
const state = cacheabilityState(context);
state.route = { kind: "app-page", pattern: "/page" };
state.outcome = { cacheable: false, dynamicUsage: true };
const response = await finalizeWorkerCacheabilityResponse(new Response("dynamic"), context);
expect(response.headers.get("Cache-Control")).toContain("no-store");
await expect(response.text()).resolves.toBe("dynamic");
});
it.each([undefined, "*/*", "application/json"])(
"creates fail-closed request state without an HTML Accept header (%s)",
(accept) => {
const base = { waitUntil() {} };
const headers = new Headers();
if (accept !== undefined) headers.set("Accept", accept);
const context = createWorkerCacheabilityAdmissionContext(
base,
new Request("https://example.com/page", { headers }),
null,
"build-a",
true,
);
expect(context).not.toBe(base);
expect(cacheabilityState(context).admission).toEqual({ policy: "deny" });
},
);
it("applies a pre-dispatch veto to an otherwise public Route Handler response", async () => {
const context = createWorkerCacheabilityAdmissionContext(
{ waitUntil() {} },
new Request("https://example.com/api/data", { headers: { Accept: "*/*" } }),
null,
"build-a",
true,
);
const state = cacheabilityState(context);
state.route = { kind: "app-route", pattern: "/api/data" };
state.forcedDynamicReason = "middleware is eligible for this pathname";
const response = await finalizeWorkerCacheabilityResponse(
new Response("private", { headers: { "Cache-Control": "public, s-maxage=60" } }),
context,
);
expect(response.headers.get("Cache-Control")).toContain("no-store");
await expect(response.text()).resolves.toBe("private");
});
it("preserves a safely completed Route Handler public policy", async () => {
const context = createWorkerCacheabilityAdmissionContext(
{ waitUntil() {} },
new Request("https://example.com/api/data", { headers: { Accept: "*/*" } }),
null,
"build-a",
true,
);
cacheabilityState(context).route = { kind: "app-route", pattern: "/api/data" };
const response = await finalizeWorkerCacheabilityResponse(
new Response("public", { headers: { "Cache-Control": "public, s-maxage=60" } }),
context,
);
expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=60");
await expect(response.text()).resolves.toBe("public");
});
it("preserves an independently classified hybrid Pages response", async () => {
const context = createWorkerCacheabilityAdmissionContext(
{ waitUntil() {} },
request,
null,
"build-a",
true,
);
cacheabilityState(context).preserveResponseCachePolicy = true;
const response = await finalizeWorkerCacheabilityResponse(
new Response("pages", { headers: { "Cache-Control": "public, s-maxage=300" } }),
context,
);
expect(response.headers.get("Cache-Control")).toBe("public, s-maxage=300");
await expect(response.text()).resolves.toBe("pages");
});
it("keeps responses with unsupported Vary fields private", async () => {
const context = createWorkerCacheabilityAdmissionContext(
{ waitUntil() {} },
request,
null,
"build-a",
true,
);
const state = cacheabilityState(context);
state.route = { kind: "app-page", pattern: "/page" };
state.outcome = {
cacheable: true,
cacheControl: "s-maxage=60, stale-while-revalidate=540",
};
const response = await finalizeWorkerCacheabilityResponse(
new Response("contextual", { headers: { Vary: "RSC, Cookie" } }),
context,
);
expect(response.headers.get("Cache-Control")).toContain("no-store");
await expect(response.text()).resolves.toBe("contextual");
});
it("serves an intentionally private certified response without treating it as static-to-dynamic", async () => {
// Next.js bypasses the Full Route Cache in draft mode. The HTML renderer
// intentionally returns no-store without opening a cache-write completion,
// so an absent outcome is not evidence that a dynamic API was used.
// Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-static/app-static.test.ts
const { raw } = staticManifestRoute();
const context = createWorkerCacheabilityAdmissionContext(
{ waitUntil() {} },
request,
raw,
"build-a",
);
const state = cacheabilityState(context);
state.route = { kind: "app-page", pattern: "/page" };
const response = await finalizeWorkerCacheabilityResponse(
new Response("draft", { headers: { "Cache-Control": "no-store" } }),
context,
);
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toContain("no-store");
await expect(response.text()).resolves.toBe("draft");
});
it("still rejects an observed static-to-dynamic transition", async () => {
const { raw } = staticManifestRoute();
const context = createWorkerCacheabilityAdmissionContext(
{ waitUntil() {} },
request,
raw,
"build-a",
);
const state = cacheabilityState(context);
const captureBudget = createCacheabilityAdmissionCaptureBudget(7);
state.captureBudget = captureBudget;
state.route = { kind: "app-page", pattern: "/page" };
state.outcome = { cacheable: false, dynamicUsage: true };
const response = await finalizeWorkerCacheabilityResponse(new Response("dynamic"), context);
expect(response.status).toBe(500);
expect(response.headers.get("Cache-Control")).toContain("no-store");
expect(captureBudget.reservedBytes).toBe(0);
await expect(response.text()).resolves.toContain("changed from static to dynamic");
});
const lateFinalPolicyCases: Array<{
finalHeaders: Record<string, string>;
initialPolicy: NonNullable<RouteCacheabilityState["frameworkResponseCachePolicy"]>;
name: string;
}> = [
{
finalHeaders: { "Set-Cookie": "session=private; Path=/; HttpOnly" },
initialPolicy: {},
name: "Set-Cookie",
},
{
finalHeaders: { "Cache-Control": "private, no-store" },
initialPolicy: { "cache-control": "public, s-maxage=60" },
name: "Cache-Control",
},
{
finalHeaders: { "CDN-Cache-Control": "private, no-store" },
initialPolicy: { "cdn-cache-control": "public, s-maxage=60" },
name: "CDN-Cache-Control",
},
{
finalHeaders: { "Cloudflare-CDN-Cache-Control": "private, no-store" },
initialPolicy: { "cloudflare-cdn-cache-control": "public, s-maxage=60" },
name: "Cloudflare-CDN-Cache-Control",
},
];
it.each(lateFinalPolicyCases)(
"keeps a manifest-backed response private after late $name",
async (testCase) => {
const { raw } = staticManifestRoute();
const context = createWorkerCacheabilityAdmissionContext(
{ waitUntil() {} },
request,
raw,
"build-a",
);
const state = cacheabilityState(context);
state.route = { kind: "app-page", pattern: "/page" };
state.frameworkResponseCachePolicy = testCase.initialPolicy;
state.outcome = {
cacheable: true,
cacheControl: "s-maxage=60, stale-while-revalidate=540",
};
const response = await finalizeWorkerCacheabilityResponse(
new Response("late private response", { headers: testCase.finalHeaders }),
context,
);
expect(state.finalResponseVetoReason).toBeUndefined();
expect(response.headers.get("Cache-Control")).toContain("no-store");
await expect(response.text()).resolves.toBe("late private response");
},
);
it("demotes post-certification io only with Cache Components enabled", async () => {
// Next.js makes `io()` dynamic during Cache Components prerenders but a
// no-op under legacy prerender ownership.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/request/io.ts
const { io } = await import("../packages/vinext/src/shims/cache.js");
const { setRouteCacheabilityCacheComponents } =
await import("../packages/vinext/src/shims/cacheability-classification.js");
const { runWithExecutionContext } =
await import("../packages/vinext/src/shims/request-context.js");
const { raw } = staticManifestRoute();
const render = async (cacheComponents: boolean) => {
const context = createWorkerCacheabilityAdmissionContext(
{ waitUntil() {} },
request,
raw,
"build-a",
);
const state = cacheabilityState(context);
state.route = { kind: "app-page", pattern: "/page" };
state.frameworkResponseCachePolicy = { "cache-control": "no-store" };
state.outcome = {
cacheable: true,
cacheControl: "s-maxage=60, stale-while-revalidate=540",
};
await runWithExecutionContext(context, async () => {
setRouteCacheabilityCacheComponents(cacheComponents);
await io();
});
return finalizeWorkerCacheabilityResponse(
new Response("conditional", { headers: { "Cache-Control": "no-store" } }),
context,
);
};
const cacheComponentsResponse = await render(true);
expect(cacheComponentsResponse.headers.get("Cache-Control")).toContain("no-store");
await expect(cacheComponentsResponse.text()).resolves.toBe("conditional");
const legacyResponse = await render(false);
expect(legacyResponse.headers.get("Cache-Control")).toBe(
"s-maxage=60, stale-while-revalidate=540",
);
await expect(legacyResponse.text()).resolves.toBe("conditional");
});
it("does not add admission overhead for adapters that persist completed artifacts", () => {
const base = { waitUntil() {} };
expect(createWorkerCacheabilityAdmissionContext(base, request, null, "build-a", false)).toBe(
base,
);
});
});
describe("cacheability probe finalization", () => {
function contextWith(state: RouteCacheabilityState) {
return {
[CACHEABILITY_REQUEST_STATE]: state,
waitUntil() {},
};
}
it("does not let ordinary dynamic usage hide a route 500", async () => {
const state: RouteCacheabilityState = {
captureDeadlineAt: Date.now() + 1_000,
mode: "probe",
outcome: { cacheable: false, dynamicUsage: true },
route: { kind: "app-page", pattern: "/broken" },
};
const response = await finalizeWorkerCacheabilityResponse(
new Response("broken", { status: 500 }),
contextWith(state),
);
await expect(response.json()).resolves.toMatchObject({
reason: "route returned HTTP 500",
state: "probe-failed",
status: 500,
});
});
it("recognizes only the dedicated private-cache suspension bailout", async () => {
const outcome = {
cacheable: false,
dynamicUsage: true,
reason: '"use cache: private" requires request-time execution',
};
const state: RouteCacheabilityState = {
captureDeadlineAt: Date.now() + 1_000,
mode: "probe",
outcome,
probeBailout: { kind: "private-cache", outcome },
route: { kind: "app-page", pattern: "/private" },
};
const response = await finalizeWorkerCacheabilityResponse(
new Response("discarded render", { status: 500 }),
contextWith(state),
);
await expect(response.json()).resolves.toMatchObject({
reason: outcome.reason,
state: "dynamic",
status: 500,
});
});
});