@@ -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
4049export type AppRouteParams = Record < string , string | string [ ] > ;
4150export type AppRouteDynamicUsageFn = ( ) => boolean ;
@@ -89,10 +98,93 @@ type RunAppRouteHandlerOptions = {
8998} ;
9099
91100type 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+
96188export 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}
0 commit comments