Skip to content

Commit 549601d

Browse files
committed
fix(cache): complete cacheable route handler responses
1 parent a430639 commit 549601d

13 files changed

Lines changed: 563 additions & 24 deletions

File tree

packages/vinext/src/server/app-route-handler-dispatch.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ import {
4343
type RouteHandlerCacheSetter,
4444
} from "./app-route-handler-execution.js";
4545
import { isKnownDynamicAppRoute, isValidHTTPMethod } from "./app-route-handler-runtime.js";
46+
import {
47+
beginRouteCacheability,
48+
getRouteCacheabilityDynamicReason,
49+
} from "vinext/shims/cacheability-classification";
4650
import {
4751
applyRouteHandlerMiddlewareContext,
4852
finalizeRouteHandlerResponse,
@@ -169,6 +173,9 @@ export async function dispatchAppRouteHandler(
169173
const { route } = options;
170174
const handler = route.routeHandler;
171175
const method = options.request.method.toUpperCase();
176+
if (method === "GET" || method === "HEAD") {
177+
beginRouteCacheability("app-route", route.pattern);
178+
}
172179
const revalidateSeconds = getAppRouteHandlerRevalidateSeconds(handler);
173180
const isDevelopment = options.isDevelopment ?? process.env.NODE_ENV === "development";
174181
const isProduction = options.isProduction ?? process.env.NODE_ENV === "production";
@@ -240,6 +247,7 @@ export async function dispatchAppRouteHandler(
240247

241248
if (
242249
revalidateSeconds !== null &&
250+
!getRouteCacheabilityDynamicReason() &&
243251
shouldReadAppRouteHandlerCache({
244252
dynamicConfig: handler.dynamic,
245253
handlerFn: resolvedHandlerFn,

packages/vinext/src/server/app-route-handler-execution.ts

Lines changed: 159 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
isPossibleAppRouteActionRequest,
2020
resolveAppRouteHandlerSpecialError,
2121
shouldApplyAppRouteHandlerRevalidateHeader,
22+
shouldCompleteAppRouteHandlerResponse,
2223
shouldWriteAppRouteHandlerCache,
2324
type AppRouteHandlerModule,
2425
} from "./app-route-handler-policy.js";
@@ -36,6 +37,14 @@ import {
3637
createTrackedAppRouteRequest,
3738
markKnownDynamicAppRoute,
3839
} from "./app-route-handler-runtime.js";
40+
import {
41+
getRouteCacheabilityCaptureOptions,
42+
getRouteCacheabilityDynamicReason,
43+
} from "vinext/shims/cacheability-classification";
44+
import {
45+
CACHEABILITY_PROBE_BODY_LIMIT,
46+
CACHEABILITY_PROBE_TIMEOUT_MS,
47+
} from "./cacheability-limits.js";
3948

4049
export type AppRouteParams = Record<string, string | string[]>;
4150
export type AppRouteDynamicUsageFn = () => boolean;
@@ -89,10 +98,93 @@ type RunAppRouteHandlerOptions = {
8998
};
9099

91100
type RunAppRouteHandlerResult = {
101+
didAccessDynamicRequest: () => boolean;
92102
dynamicUsedInHandler: boolean;
93103
response: Response;
94104
};
95105

106+
type CompletedAppRouteHandlerResponse = {
107+
completed: boolean;
108+
response: Response;
109+
};
110+
111+
async function completeAppRouteHandlerResponse(
112+
response: Response,
113+
): Promise<CompletedAppRouteHandlerResponse> {
114+
// Match Next.js static App Route generation: resolve only after clean EOF,
115+
// then rebuild the response from the completed body. Besides making the ISR
116+
// artifact deterministic, this keeps request tracking active for stream
117+
// pulls and turns a late body failure into the normal Route Handler error
118+
// path before cacheable response headers are applied.
119+
// Workers cannot safely buffer an unbounded or never-ending response. Reuse
120+
// admission's isolate-wide bounded capture and fall back to private streaming
121+
// when the response exceeds either the size or completion deadline.
122+
const { captureCacheabilityAdmissionBody } = await import("./cacheability-request.js");
123+
const captureOptions = getRouteCacheabilityCaptureOptions();
124+
const captured = await captureCacheabilityAdmissionBody(
125+
response.body,
126+
captureOptions?.captureDeadlineAt ?? Date.now() + CACHEABILITY_PROBE_TIMEOUT_MS,
127+
CACHEABILITY_PROBE_BODY_LIMIT,
128+
captureOptions?.captureBudget,
129+
);
130+
const completed = new Response(captured.body, {
131+
headers: response.headers,
132+
status: response.status,
133+
statusText: response.statusText,
134+
});
135+
copyLinkHeaderProvenance(response.headers, completed.headers);
136+
return { completed: captured.kind === "captured", response: completed };
137+
}
138+
139+
function deferAppRouteHandlerCleanup(response: Response, cleanup: () => Promise<void>): Response {
140+
if (!response.body) {
141+
void cleanup();
142+
return response;
143+
}
144+
145+
const reader = response.body.getReader();
146+
let cleaned = false;
147+
const cleanOnce = async () => {
148+
if (cleaned) return;
149+
cleaned = true;
150+
reader.releaseLock();
151+
await cleanup();
152+
};
153+
const body = new ReadableStream<Uint8Array>(
154+
{
155+
async pull(controller) {
156+
try {
157+
const result = await reader.read();
158+
if (result.done) {
159+
await cleanOnce();
160+
controller.close();
161+
} else {
162+
controller.enqueue(result.value);
163+
}
164+
} catch (error) {
165+
await cleanOnce();
166+
controller.error(error);
167+
}
168+
},
169+
async cancel(reason) {
170+
try {
171+
await reader.cancel(reason);
172+
} finally {
173+
await cleanOnce();
174+
}
175+
},
176+
},
177+
{ highWaterMark: 0 },
178+
);
179+
const deferred = new Response(body, {
180+
headers: response.headers,
181+
status: response.status,
182+
statusText: response.statusText,
183+
});
184+
copyLinkHeaderProvenance(response.headers, deferred.headers);
185+
return deferred;
186+
}
187+
96188
export function applyDraftModeCachePolicy(response: Response, isDraftMode: boolean): Response {
97189
if (!isDraftMode) return response;
98190

@@ -184,8 +276,10 @@ export async function runAppRouteHandler(
184276
}),
185277
);
186278

279+
const dynamicUsedInContext = options.consumeDynamicUsage();
187280
return {
188-
dynamicUsedInHandler: options.consumeDynamicUsage(),
281+
didAccessDynamicRequest: () => trackedRequest.didAccessDynamicRequest(),
282+
dynamicUsedInHandler: trackedRequest.didAccessDynamicRequest() || dynamicUsedInContext,
189283
response,
190284
};
191285
}
@@ -194,6 +288,7 @@ export async function executeAppRouteHandler(
194288
options: ExecuteAppRouteHandlerOptions,
195289
): Promise<Response> {
196290
const previousHeadersPhase = options.setHeadersAccessPhase("route-handler");
291+
let cleanupDeferredToBody = false;
197292
const middlewareMergeOptions = {
198293
appendResponseLink:
199294
options.handler.runtime === "edge" || options.handler.runtime === "experimental-edge",
@@ -212,16 +307,48 @@ export async function executeAppRouteHandler(
212307
// finalization clears the request context.
213308
await _drainPendingRevalidations();
214309
}
215-
const { dynamicUsedInHandler, response } = handlerResult;
310+
let { dynamicUsedInHandler, response } = handlerResult;
216311
assertSupportedAppRouteHandlerResponse(response);
217312
const handlerSetCacheControl = response.headers.has("cache-control");
218313

314+
const draftModeBeforeCompletion =
315+
options.getActiveDraftModeState?.() ?? options.isDraftMode === true;
316+
const handlerDraftCookieBeforeCompletion =
317+
options.getDraftModeCookieHeader() ?? options.initialDraftModeCookie;
318+
if (
319+
shouldCompleteAppRouteHandlerResponse({
320+
dynamicConfig: options.handler.dynamic,
321+
dynamicUsedInHandler,
322+
handlerSetCacheControl,
323+
isAutoHead: options.isAutoHead,
324+
isDraftMode: draftModeBeforeCompletion || handlerDraftCookieBeforeCompletion != null,
325+
isProduction: options.isProduction,
326+
method: options.method,
327+
revalidateSeconds: options.revalidateSeconds,
328+
})
329+
) {
330+
const completed = await completeAppRouteHandlerResponse(response);
331+
response = completed.response;
332+
cleanupDeferredToBody = !completed.completed;
333+
const dynamicUsedDuringCompletion = options.consumeDynamicUsage();
334+
dynamicUsedInHandler =
335+
handlerResult.didAccessDynamicRequest() ||
336+
dynamicUsedDuringCompletion ||
337+
dynamicUsedInHandler;
338+
}
339+
340+
const requestCacheabilityVeto = getRouteCacheabilityDynamicReason();
341+
const responseMustStayPrivate = Boolean(
342+
dynamicUsedInHandler || requestCacheabilityVeto || cleanupDeferredToBody,
343+
);
344+
219345
if (dynamicUsedInHandler) {
220346
markKnownDynamicAppRoute(options.routePattern);
221347
}
222348

223349
const pendingCookies = options.getAndClearPendingCookies();
224-
const handlerDraftCookie = options.getDraftModeCookieHeader();
350+
const handlerDraftCookie =
351+
options.getDraftModeCookieHeader() ?? handlerDraftCookieBeforeCompletion;
225352
const draftCookie = handlerDraftCookie ?? options.initialDraftModeCookie;
226353
const activeDraftMode = options.getActiveDraftModeState?.() ?? options.isDraftMode === true;
227354
const shouldApplyDraftPolicy = activeDraftMode || draftCookie != null;
@@ -242,7 +369,7 @@ export async function executeAppRouteHandler(
242369

243370
if (
244371
shouldApplyAppRouteHandlerRevalidateHeader({
245-
dynamicUsedInHandler,
372+
dynamicUsedInHandler: responseMustStayPrivate,
246373
handlerSetCacheControl,
247374
isAutoHead: options.isAutoHead,
248375
isDraftMode: shouldApplyDraftPolicy,
@@ -265,7 +392,7 @@ export async function executeAppRouteHandler(
265392
if (
266393
shouldWriteAppRouteHandlerCache({
267394
dynamicConfig: options.handler.dynamic,
268-
dynamicUsedInHandler,
395+
dynamicUsedInHandler: responseMustStayPrivate,
269396
handlerSetCacheControl,
270397
isAutoHead: options.isAutoHead,
271398
isDraftMode: shouldApplyDraftPolicy,
@@ -298,9 +425,7 @@ export async function executeAppRouteHandler(
298425
options.executionContext?.waitUntil(routeWritePromise);
299426
}
300427

301-
options.clearRequestContext();
302-
303-
return applyDraftModeCachePolicy(
428+
let finalized = applyDraftModeCachePolicy(
304429
applyRouteHandlerMiddlewareContext(
305430
finalizeRouteHandlerResponse(response, {
306431
pendingCookies,
@@ -312,6 +437,31 @@ export async function executeAppRouteHandler(
312437
),
313438
shouldApplyDraftPolicy,
314439
);
440+
if (responseMustStayPrivate) {
441+
const headers = new Headers(finalized.headers);
442+
applyCdnResponseHeaders(headers, { cacheControl: NEVER_CACHE_CONTROL });
443+
finalized = new Response(finalized.body, {
444+
headers,
445+
status: finalized.status,
446+
statusText: finalized.statusText,
447+
});
448+
copyLinkHeaderProvenance(response.headers, finalized.headers);
449+
}
450+
451+
if (!cleanupDeferredToBody) {
452+
options.clearRequestContext();
453+
return finalized;
454+
}
455+
456+
return deferAppRouteHandlerCleanup(finalized, async () => {
457+
try {
458+
await _drainPendingRevalidations();
459+
options.consumeDynamicUsage();
460+
} finally {
461+
options.clearRequestContext();
462+
options.setHeadersAccessPhase(previousHeadersPhase);
463+
}
464+
});
315465
} catch (error) {
316466
const pendingCookies = options.getAndClearPendingCookies();
317467
const handlerDraftCookie = options.getDraftModeCookieHeader();
@@ -382,6 +532,6 @@ export async function executeAppRouteHandler(
382532
shouldApplyDraftPolicy,
383533
);
384534
} finally {
385-
options.setHeadersAccessPhase(previousHeadersPhase);
535+
if (!cleanupDeferredToBody) options.setHeadersAccessPhase(previousHeadersPhase);
386536
}
387537
}

packages/vinext/src/server/app-route-handler-policy.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export { isPossibleAppRouteActionRequest } from "./app-action-request.js";
1010
export type AppRouteHandlerModule = {
1111
dynamic?: string;
1212
fetchCache?: unknown;
13+
generateStaticParams?: unknown;
1314
revalidate?: unknown;
1415
runtime?: string;
1516
} & RouteHandlerModule;
@@ -62,18 +63,32 @@ type AppRouteHandlerSpecialErrorOptions = {
6263
};
6364

6465
export function getAppRouteHandlerRevalidateSeconds(
65-
handler: Pick<AppRouteHandlerModule, "revalidate">,
66+
handler: Pick<AppRouteHandlerModule, "dynamic" | "generateStaticParams" | "revalidate">,
6667
): number | null {
6768
// 0 is a meaningful value ("never cache") and must be preserved so the
6869
// header path can emit a no-store Cache-Control.
6970
// revalidate = false means "cache indefinitely" (Next.js segment config
7071
// parity) — return Infinity to signal the cache-later path.
7172
const { revalidate } = handler;
7273
if (revalidate === false) return Infinity;
73-
if (typeof revalidate !== "number" || !Number.isFinite(revalidate) || revalidate < 0) {
74+
if (typeof revalidate === "number" && Number.isFinite(revalidate) && revalidate >= 0) {
75+
return revalidate;
76+
}
77+
if (revalidate !== undefined) {
7478
return null;
7579
}
76-
return revalidate;
80+
81+
// Ported from Next.js static eligibility:
82+
// packages/next/src/server/route-modules/app-route/helpers/is-static-gen-enabled.ts
83+
if (
84+
handler.dynamic === "force-static" ||
85+
handler.dynamic === "error" ||
86+
typeof handler.generateStaticParams === "function"
87+
) {
88+
return Infinity;
89+
}
90+
91+
return null;
7792
}
7893

7994
export function hasAppRouteHandlerDefaultExport(handler: RouteHandlerModule): boolean {
@@ -144,6 +159,26 @@ export function shouldApplyAppRouteHandlerRevalidateHeader(
144159
);
145160
}
146161

162+
/**
163+
* Next.js consumes the full body before completing static generation for an
164+
* App Route. Keep that completion boundary for every response that can still
165+
* receive a public framework cache policy, including
166+
* `revalidate = false` (represented by Infinity).
167+
*/
168+
export function shouldCompleteAppRouteHandlerResponse(
169+
options: AppRouteHandlerResponseCacheOptions,
170+
): boolean {
171+
return (
172+
options.isProduction &&
173+
options.revalidateSeconds !== null &&
174+
options.revalidateSeconds > 0 &&
175+
options.dynamicConfig !== "force-dynamic" &&
176+
!options.isDraftMode &&
177+
!options.dynamicUsedInHandler &&
178+
(options.method === "GET" || options.isAutoHead)
179+
);
180+
}
181+
147182
export function shouldWriteAppRouteHandlerCache(
148183
options: AppRouteHandlerResponseCacheOptions,
149184
): boolean {

packages/vinext/src/server/app-router-entry.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ import {
5656
import {
5757
NEXT_ACTION_HEADER,
5858
RSC_ACTION_HEADER,
59-
RSC_HEADER,
6059
VINEXT_CACHEABILITY_PROBE_HEADER,
6160
VINEXT_PRERENDER_ROUTE_PARAMS_HEADER,
6261
VINEXT_PRERENDER_SECRET_HEADER,
@@ -93,9 +92,7 @@ function isPotentialCompletedAdmissionRequest(request: Request): boolean {
9392
if (request.method !== "GET" && request.method !== "HEAD") return false;
9493
if (request.headers.has(NEXT_ACTION_HEADER) || request.headers.has(RSC_ACTION_HEADER))
9594
return false;
96-
if (request.headers.get(RSC_HEADER) === "1") return true;
97-
if (request.headers.get("Accept")?.toLowerCase().includes("text/html")) return true;
98-
return new URL(request.url).pathname.endsWith(".rsc");
95+
return true;
9996
}
10097

10198
export default {

0 commit comments

Comments
 (0)