Skip to content

Commit 9b5d065

Browse files
committed
fix(cache): trust framework admission policy
1 parent 3354c7d commit 9b5d065

5 files changed

Lines changed: 75 additions & 8 deletions

File tree

packages/vinext/src/server/app-rsc-response-finalizer.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { hasBasePath, stripBasePath } from "../utils/base-path.js";
1313
import { normalizeDefaultLocalePathname } from "./pages-i18n.js";
1414
import { sanitizeMethodNotAllowedHeaders } from "./http-error-responses.js";
1515
import { hasPostConfigLinkHeaders } from "./app-response-header-provenance.js";
16+
import { captureRouteCacheabilityResponsePolicy } from "vinext/shims/cacheability-classification";
1617

1718
type FinalizeAppRscResponseOptions = {
1819
basePath: string;
@@ -123,6 +124,11 @@ export async function finalizeAppRscResponse(
123124
// already applied. Redirects are already skipped above.
124125
if (!response.headers.has("Cache-Control")) {
125126
applyCdnResponseHeaders(response.headers, { cacheControl: "" });
127+
// This is the adapter's fail-closed provisional policy, not an
128+
// application opt-out. Admission may replace it only after the body has
129+
// completed and the render has proved reusable. Capture before config
130+
// headers run so any later private/no-store override still vetoes.
131+
captureRouteCacheabilityResponsePolicy(response.headers);
126132
}
127133

128134
if (configHeadersAlreadyApplied.has(response)) {

packages/vinext/src/server/cacheability-request.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ function hasStrictFinalResponseVeto(response: Response, state: RouteCacheability
413413
const value = response.headers.get(name);
414414
if (
415415
value !== null &&
416-
value !== state.initialResponseCachePolicy?.[name] &&
416+
value !== state.frameworkResponseCachePolicy?.[name] &&
417417
isNonCacheableCacheControl(value)
418418
) {
419419
return true;

packages/vinext/src/shims/cacheability-classification.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export type RouteCacheabilityState = {
3333
completion?: Promise<RouteCacheabilityOutcome>;
3434
finalResponseVetoReason?: string;
3535
forcedDynamicReason?: string;
36-
initialResponseCachePolicy?: Partial<Record<CacheabilityPolicyHeader, string>>;
36+
frameworkResponseCachePolicy?: Partial<Record<CacheabilityPolicyHeader, string>>;
3737
mode: "admit" | "identity" | "probe";
3838
outcome?: RouteCacheabilityOutcome;
3939
route?: {
@@ -71,17 +71,23 @@ export function markRouteCacheabilityFinalResponseUncacheable(reason: string): v
7171
state.finalResponseVetoReason ??= reason;
7272
}
7373

74-
/** Record renderer-owned policy so admission can identify policy added later. */
74+
/** Record framework-owned policy so admission can identify policy added later. */
7575
export function captureRouteCacheabilityResponsePolicy(headers: Headers): void {
7676
const state = readRouteCacheabilityState();
77-
if (!state || state.mode !== "admit" || state.initialResponseCachePolicy) return;
77+
if (!state || state.mode !== "admit") return;
7878

7979
const policy: Partial<Record<CacheabilityPolicyHeader, string>> = {};
8080
for (const name of CACHEABILITY_POLICY_HEADERS) {
8181
const value = headers.get(name);
8282
if (value !== null) policy[name] = value;
8383
}
84-
state.initialResponseCachePolicy = policy;
84+
// Framework response shaping has more than one trusted phase. In
85+
// particular, the App Page renderer can leave Cache-Control absent before
86+
// the outer response finalizer applies the adapter's provisional no-store
87+
// default. Keep the latest trusted snapshot; configurable response headers
88+
// run after the final capture and remain visible to the strict admission
89+
// comparison below.
90+
state.frameworkResponseCachePolicy = policy;
8591
}
8692

8793
/** True only for an authenticated probe that must render the matched App Page. */

tests/app-rsc-response-finalizer.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import {
1616
type CdnResponseHeaders,
1717
} from "../packages/vinext/src/shims/cdn-cache.js";
1818
import { createStaticFileSignal } from "../packages/vinext/src/server/request-pipeline.js";
19+
import { createWorkerCacheabilityAdmissionContext } from "../packages/vinext/src/server/cacheability-request.js";
20+
import {
21+
CACHEABILITY_REQUEST_STATE,
22+
type RouteCacheabilityState,
23+
} from "../packages/vinext/src/shims/cacheability-classification.js";
24+
import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js";
1925

2026
afterEach(() => setCdnCacheAdapter(new DefaultCdnCacheAdapter()));
2127

@@ -94,6 +100,55 @@ describe("finalizeAppRscResponse — config header application", () => {
94100
expect(response.headers.get("x-example-edge-policy")).toBeNull();
95101
});
96102

103+
it("records the adapter's provisional policy before config headers run", async () => {
104+
const adapter: CdnCacheAdapter = {
105+
ownsBackgroundRevalidation: true,
106+
async get() {
107+
return null;
108+
},
109+
async set() {},
110+
buildResponseHeaders({ cacheControl }): CdnResponseHeaders {
111+
return { "Cache-Control": cacheControl || "no-store" };
112+
},
113+
hasExplicitNonCacheableResponsePolicy(headers) {
114+
return headers.get("Cache-Control")?.includes("no-store") === true;
115+
},
116+
async revalidateTag() {},
117+
};
118+
setCdnCacheAdapter(adapter);
119+
const request = new Request("http://example.com/about", {
120+
headers: { Accept: "text/html" },
121+
});
122+
const executionContext = createWorkerCacheabilityAdmissionContext(
123+
{ waitUntil() {} },
124+
request,
125+
null,
126+
"build-a",
127+
true,
128+
);
129+
130+
await runWithExecutionContext(executionContext, () =>
131+
finalizeAppRscResponse(new Response("body"), request, {
132+
basePath: "",
133+
configHeaders: [
134+
{
135+
source: "/about",
136+
headers: [{ key: "Cache-Control", value: "private, no-store" }],
137+
},
138+
],
139+
i18nConfig: null,
140+
requestContext: makeRequestContext(),
141+
}),
142+
);
143+
144+
const state = Reflect.get(
145+
executionContext,
146+
CACHEABILITY_REQUEST_STATE,
147+
) as RouteCacheabilityState;
148+
expect(state.frameworkResponseCachePolicy).toEqual({ "cache-control": "no-store" });
149+
expect(state.finalResponseVetoReason).toContain("next.config headers set a non-cacheable");
150+
});
151+
97152
it("applies a matching config header to a 200 response", async () => {
98153
// Behavior: /about page response gets x-added header from next.config.js headers[].
99154
// Regression: expected null to be "config"

tests/cacheability-admission.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ describe("single-request cacheability admission", () => {
126126
cacheable: true,
127127
cacheControl: "s-maxage=60, stale-while-revalidate=540",
128128
};
129-
state.initialResponseCachePolicy = { "cache-control": "no-store" };
129+
state.frameworkResponseCachePolicy = { "cache-control": "no-store" };
130130

131131
const response = await finalizeWorkerCacheabilityResponse(
132132
new Response("static", { headers: { "Cache-Control": "no-store" } }),
@@ -225,7 +225,7 @@ describe("single-request cacheability admission", () => {
225225

226226
const lateFinalPolicyCases: Array<{
227227
finalHeaders: Record<string, string>;
228-
initialPolicy: NonNullable<RouteCacheabilityState["initialResponseCachePolicy"]>;
228+
initialPolicy: NonNullable<RouteCacheabilityState["frameworkResponseCachePolicy"]>;
229229
name: string;
230230
}> = [
231231
{
@@ -262,7 +262,7 @@ describe("single-request cacheability admission", () => {
262262
);
263263
const state = cacheabilityState(context);
264264
state.route = { kind: "app-page", pattern: "/page" };
265-
state.initialResponseCachePolicy = testCase.initialPolicy;
265+
state.frameworkResponseCachePolicy = testCase.initialPolicy;
266266
state.outcome = {
267267
cacheable: true,
268268
cacheControl: "s-maxage=60, stale-while-revalidate=540",

0 commit comments

Comments
 (0)