Skip to content

Commit 7cdf5d3

Browse files
committed
Merge updated PR cloudflare#2710 head into static byte ranges
2 parents a8a36f3 + 14ada51 commit 7cdf5d3

12 files changed

Lines changed: 743 additions & 51 deletions

File tree

packages/vinext/src/server/app-rsc-handler.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
closeAfterResponse,
3333
closeAfterResponseWithBody,
3434
createRequestContext,
35+
preserveFullyBufferedBodyMetadata,
3536
runWithRequestContext,
3637
} from "vinext/shims/unified-request-context";
3738
import { flattenErrorCauses } from "../utils/error-cause.js";
@@ -146,11 +147,14 @@ function applyMiddlewareContextToResponse(
146147
const headers = new Headers(response.headers);
147148
mergeMiddlewareResponseHeaders(headers, middlewareContext.headers);
148149

149-
return new Response(response.body, {
150-
status: middlewareContext.status ?? response.status,
151-
statusText: response.statusText,
152-
headers,
153-
});
150+
return preserveFullyBufferedBodyMetadata(
151+
response,
152+
new Response(response.body, {
153+
status: middlewareContext.status ?? response.status,
154+
statusText: response.statusText,
155+
headers,
156+
}),
157+
);
154158
}
155159

156160
type DispatchMatchedPageOptions<TRoute> = {

packages/vinext/src/server/metadata-route-response.ts

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type SitemapEntry,
1111
} from "./metadata-routes.js";
1212
import { notFoundResponse } from "./http-error-responses.js";
13+
import { markFullyBufferedBody } from "vinext/shims/unified-request-context";
1314

1415
type AppPageParams = Record<string, string | string[]>;
1516
type MetadataRouteFunction = (props: Record<string, unknown>) => unknown;
@@ -223,12 +224,15 @@ async function handleGeneratedSitemap(
223224
if (!isSitemapEntries(result)) {
224225
throw new TypeError("Metadata sitemap routes must return an array.");
225226
}
226-
return new Response(sitemapToXml(result), {
227-
headers: {
228-
"Content-Type": route.contentType,
229-
"Cache-Control": metadataRouteCacheHeader(route),
230-
},
231-
});
227+
// Body serialized to a string here — fully materialized, no producer left.
228+
return markFullyBufferedBody(
229+
new Response(sitemapToXml(result), {
230+
headers: {
231+
"Content-Type": route.contentType,
232+
"Cache-Control": metadataRouteCacheHeader(route),
233+
},
234+
}),
235+
);
232236
}
233237

234238
function findGeneratedImageId(
@@ -327,12 +331,15 @@ async function callDynamicMetadataRoute(
327331
body = JSON.stringify(result);
328332
}
329333

330-
return new Response(body, {
331-
headers: {
332-
"Content-Type": route.contentType,
333-
"Cache-Control": metadataRouteCacheHeader(route),
334-
},
335-
});
334+
// Every branch above serialized `result` to a string — fully materialized.
335+
return markFullyBufferedBody(
336+
new Response(body, {
337+
headers: {
338+
"Content-Type": route.contentType,
339+
"Cache-Control": metadataRouteCacheHeader(route),
340+
},
341+
}),
342+
);
336343
}
337344

338345
function serveStaticMetadataRoute(route: MetadataRuntimeRoute): Response {
@@ -348,12 +355,15 @@ function serveStaticMetadataRoute(route: MetadataRuntimeRoute): Response {
348355
for (let index = 0; index < binary.length; index++) {
349356
bytes[index] = binary.charCodeAt(index);
350357
}
351-
return new Response(bytes, {
352-
headers: {
353-
"Content-Type": route.contentType,
354-
"Cache-Control": metadataRouteCacheHeader(route),
355-
},
356-
});
358+
// Static file bytes, fully in memory — no producer left.
359+
return markFullyBufferedBody(
360+
new Response(bytes, {
361+
headers: {
362+
"Content-Type": route.contentType,
363+
"Cache-Control": metadataRouteCacheHeader(route),
364+
},
365+
}),
366+
);
357367
} catch (error) {
358368
const reason = error instanceof Error && error.message ? `: ${error.message}` : "";
359369
throw new Error(

packages/vinext/src/shims/server.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
getRequestContext,
2424
isInsideUnifiedScope,
2525
queueAfterCallback,
26+
trackAfterPromise,
2627
} from "./unified-request-context.js";
2728
import { assertSafeNavigationUrl } from "./url-safety.js";
2829
import { hasBasePath, stripBasePath } from "../utils/base-path.js";
@@ -1243,9 +1244,12 @@ export function after<T>(task: Promise<T> | (() => T | Promise<T>)): void {
12431244
if (task == null || typeof (task as PromiseLike<T>).then !== "function") {
12441245
throw new TypeError("`after()`: Argument must be a promise or a function");
12451246
}
1246-
const guarded = Promise.resolve(task).catch((error) => {
1247-
console.error("[vinext] after() task failed:", error);
1248-
});
1247+
const guarded = trackAfterPromise(
1248+
requestContext,
1249+
Promise.resolve(task).catch((error) => {
1250+
console.error("[vinext] after() task failed:", error);
1251+
}),
1252+
);
12491253
getRequestExecutionContext()?.waitUntil(guarded);
12501254
return;
12511255
}

packages/vinext/src/shims/unified-request-context.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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. */
198208
export 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+
*/
230297
export 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

Comments
 (0)