mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
498 lines
17 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|