@@ -63,6 +63,7 @@ export type AfterRequestContext = {
6363 callbacks : Array < ( ) => unknown > ;
6464 responseClosed : boolean ;
6565 pendingCallbacks : number ;
66+ pendingPromises : number ;
6667 completion : Promise < void > | null ;
6768 resolveCompletion : ( ( ) => void ) | null ;
6869} ;
@@ -129,6 +130,7 @@ export function createRequestContext(opts?: Partial<UnifiedRequestContext>): Uni
129130 callbacks : [ ] ,
130131 responseClosed : false ,
131132 pendingCallbacks : 0 ,
133+ pendingPromises : 0 ,
132134 completion : null ,
133135 resolveCompletion : null ,
134136 } ,
@@ -194,6 +196,14 @@ export function queueAfterCallback(ctx: UnifiedRequestContext, callback: () => u
194196 }
195197}
196198
199+ /** Track promise-form after() work that can register a callback before settling. */
200+ export function trackAfterPromise < T > ( ctx : UnifiedRequestContext , promise : Promise < T > ) : Promise < T > {
201+ ctx . afterContext . pendingPromises += 1 ;
202+ return promise . finally ( ( ) => {
203+ ctx . afterContext . pendingPromises -= 1 ;
204+ } ) ;
205+ }
206+
197207/** Bind a callback to every AsyncLocalStorage context active at registration. */
198208export function bindRequestContextSnapshot < T > (
199209 ctx : UnifiedRequestContext ,
@@ -226,16 +236,80 @@ export async function closeAfterResponse(ctx: UnifiedRequestContext): Promise<vo
226236 return state . completion ?? Promise . resolve ( ) ;
227237}
228238
229- /** Wrap a response so deferred callbacks start on stream completion or cancellation. */
239+ /**
240+ * Whether this request has function-form `after()` work that still needs to
241+ * observe the response body closing.
242+ *
243+ * Promise-form `after(promise)` is included because its continuation can
244+ * register a function-form `after()` before the promise settles. Function-form
245+ * work needs the body's close observed because its contract is "run once the
246+ * response has been sent". `resolveCompletion` stays non-null for as long as
247+ * any callback is queued or in flight, so checking it alongside the explicit
248+ * counters keeps this correct on re-entry after callbacks have started.
249+ */
250+ export function requiresResponseCloseTracking ( ctx : UnifiedRequestContext ) : boolean {
251+ const state = ctx . afterContext ;
252+ return (
253+ state . callbacks . length > 0 ||
254+ state . pendingCallbacks > 0 ||
255+ state . pendingPromises > 0 ||
256+ state . resolveCompletion !== null
257+ ) ;
258+ }
259+
260+ type ResponseWithFullyBufferedBodyMetadata = Response & {
261+ __vinextFullyBufferedBody ?: boolean ;
262+ } ;
263+
264+ /**
265+ * Mark a response whose body vinext constructed from a fully in-memory string
266+ * or byte array, as opposed to a body handed back by user code, which could
267+ * still be producing. With no producer left, no `after()` call can originate
268+ * from this body — the one signal that makes it safe for
269+ * `closeAfterResponseWithBody()` to skip close tracking.
270+ *
271+ * Not set for a metadata route's `result instanceof Response` passthrough (a
272+ * user `icon.tsx`/`opengraph-image.tsx` can return a streaming
273+ * `ImageResponse`) or any handler-returned `new Response(stream)` — those
274+ * bodies can still be producing and must keep close tracking.
275+ */
276+ export function markFullyBufferedBody ( response : Response ) : Response {
277+ ( response as ResponseWithFullyBufferedBodyMetadata ) . __vinextFullyBufferedBody = true ;
278+ return response ;
279+ }
280+
281+ function isFullyBufferedBody ( response : Response ) : boolean {
282+ return ( response as ResponseWithFullyBufferedBodyMetadata ) . __vinextFullyBufferedBody === true ;
283+ }
284+
285+ /** Preserve the internal buffered-body signal when response metadata is rebuilt. */
286+ export function preserveFullyBufferedBodyMetadata ( source : Response , target : Response ) : Response {
287+ return isFullyBufferedBody ( source ) ? markFullyBufferedBody ( target ) : target ;
288+ }
289+
290+ /**
291+ * Wrap a response so deferred `after()` callbacks start on stream completion
292+ * or cancellation. Skipped only when the body is marked fully buffered (see
293+ * `markFullyBufferedBody`) and nothing is currently registered — that lets
294+ * the runtime send it with an accurate `Content-Length` instead of chunked
295+ * transfer encoding.
296+ */
230297export function closeAfterResponseWithBody (
231298 response : Response ,
232299 ctx : UnifiedRequestContext ,
233300) : Response {
234301 if ( ! response . body ) {
302+ // Resolve the after-lifecycle now (a no-op if nothing is queued) so a
303+ // callback registered after this call returns doesn't wait forever on a
304+ // responseClosed flag nothing else will set.
235305 queueMicrotask ( ( ) => void closeAfterResponse ( ctx ) ) ;
236306 return response ;
237307 }
238308
309+ if ( isFullyBufferedBody ( response ) && ! requiresResponseCloseTracking ( ctx ) ) {
310+ return response ;
311+ }
312+
239313 const passthrough = new TransformStream < Uint8Array , Uint8Array > ( ) ;
240314 void response . body . pipeTo ( passthrough . writable ) . then (
241315 ( ) => void closeAfterResponse ( ctx ) ,
0 commit comments