-
Notifications
You must be signed in to change notification settings - Fork 253
fix: Pages Router SSR streaming #514
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
914e483
4565b58
25dc13a
d2b4655
4877173
46e7de5
7def74c
552e07c
f0dd479
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -813,17 +813,34 @@ export default { | |
| * Response headers take precedence over middleware headers for all headers | ||
| * except Set-Cookie, which is additive (both middleware and response cookies | ||
| * are preserved). Matches the behavior in prod-server.ts. Uses getSetCookie() | ||
| * to preserve multiple Set-Cookie values. | ||
| * to preserve multiple Set-Cookie values. Keep this in sync with | ||
| * prod-server.ts and server/worker-utils.ts. | ||
| */ | ||
| function mergeHeaders( | ||
| response: Response, | ||
| extraHeaders: Record<string, string | string[]>, | ||
| statusOverride?: number, | ||
| ): Response { | ||
| if (!Object.keys(extraHeaders).length && !statusOverride) return response; | ||
| const NO_BODY_RESPONSE_STATUSES = new Set([204, 205, 304]); | ||
| function isVinextStreamedHtmlResponse(response: Response): boolean { | ||
| return response.__vinextStreamedHtmlResponse === true; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: In the deploy.ts template, |
||
| } | ||
| function isContentLengthHeader(name: string): boolean { | ||
| return name.toLowerCase() === "content-length"; | ||
| } | ||
| function cancelResponseBody(response: Response): void { | ||
| const body = response.body; | ||
| if (!body || body.locked) return; | ||
| void body.cancel().catch(() => { | ||
| /* ignore cancellation failures on discarded bodies */ | ||
| }); | ||
| } | ||
|
|
||
| const status = statusOverride ?? response.status; | ||
| const merged = new Headers(); | ||
| // Middleware/config headers go in first (lower precedence) | ||
| for (const [k, v] of Object.entries(extraHeaders)) { | ||
| if (isContentLengthHeader(k)) continue; | ||
| if (Array.isArray(v)) { | ||
| for (const item of v) merged.append(k, item); | ||
| } else { | ||
|
|
@@ -838,9 +855,40 @@ function mergeHeaders( | |
| }); | ||
| const responseCookies = response.headers.getSetCookie?.() ?? []; | ||
| for (const cookie of responseCookies) merged.append("set-cookie", cookie); | ||
|
|
||
| const shouldDropBody = NO_BODY_RESPONSE_STATUSES.has(status); | ||
| const shouldStripStreamLength = | ||
| isVinextStreamedHtmlResponse(response) && merged.has("content-length"); | ||
|
|
||
| if ( | ||
| !Object.keys(extraHeaders).some((key) => !isContentLengthHeader(key)) && | ||
| statusOverride === undefined && | ||
| !shouldDropBody && | ||
| !shouldStripStreamLength | ||
| ) { | ||
| return response; | ||
| } | ||
|
|
||
| if (shouldDropBody) { | ||
| cancelResponseBody(response); | ||
| merged.delete("content-encoding"); | ||
| merged.delete("content-length"); | ||
| merged.delete("content-type"); | ||
| merged.delete("transfer-encoding"); | ||
| return new Response(null, { | ||
| status, | ||
| statusText: status === response.status ? response.statusText : undefined, | ||
| headers: merged, | ||
| }); | ||
| } | ||
|
|
||
| if (shouldStripStreamLength) { | ||
| merged.delete("content-length"); | ||
| } | ||
|
|
||
| return new Response(response.body, { | ||
| status: statusOverride ?? response.status, | ||
| statusText: response.statusText, | ||
| status, | ||
| statusText: status === response.status ? response.statusText : undefined, | ||
| headers: merged, | ||
| }); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1108,7 +1108,14 @@ async function _renderPage(request, url, manifest) { | |
| if (_fontLinkHeader) { | ||
| responseHeaders.set("Link", _fontLinkHeader); | ||
| } | ||
| return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); | ||
| const streamedPageResponse = new Response(compositeStream, { | ||
| status: finalStatus, | ||
| headers: responseHeaders, | ||
| }); | ||
| // Mark the normal streamed HTML render so the Node prod server can strip | ||
| // stale Content-Length only for this path, not for custom gSSP responses. | ||
| streamedPageResponse.__vinextStreamedHtmlResponse = true; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using an ad-hoc expando property on the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using an ad-hoc expando property on the Consider adding a comment here like: Alternatively, a custom response header (e.g., |
||
| return streamedPageResponse; | ||
| } catch (e) { | ||
| console.error("[vinext] SSR error:", e); | ||
| _reportRequestError( | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -157,6 +157,107 @@ function mergeResponseHeaders( | |||||||||||||||||
| return merged; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| function toWebHeaders(headersRecord: Record<string, string | string[]>): Headers { | ||||||||||||||||||
| const headers = new Headers(); | ||||||||||||||||||
| for (const [key, value] of Object.entries(headersRecord)) { | ||||||||||||||||||
| if (Array.isArray(value)) { | ||||||||||||||||||
| for (const item of value) headers.append(key, item); | ||||||||||||||||||
| } else { | ||||||||||||||||||
| headers.set(key, value); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| return headers; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const NO_BODY_RESPONSE_STATUSES = new Set([204, 205, 304]); | ||||||||||||||||||
|
|
||||||||||||||||||
| function hasHeader(headersRecord: Record<string, string | string[]>, name: string): boolean { | ||||||||||||||||||
| const target = name.toLowerCase(); | ||||||||||||||||||
| return Object.keys(headersRecord).some((key) => key.toLowerCase() === target); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| function stripHeaders( | ||||||||||||||||||
| headersRecord: Record<string, string | string[]>, | ||||||||||||||||||
| names: readonly string[], | ||||||||||||||||||
| ): void { | ||||||||||||||||||
| const targets = new Set(names.map((name) => name.toLowerCase())); | ||||||||||||||||||
| for (const key of Object.keys(headersRecord)) { | ||||||||||||||||||
| if (targets.has(key.toLowerCase())) delete headersRecord[key]; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| function isNoBodyResponseStatus(status: number): boolean { | ||||||||||||||||||
| return NO_BODY_RESPONSE_STATUSES.has(status); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| function cancelResponseBody(response: Response): void { | ||||||||||||||||||
| const body = response.body; | ||||||||||||||||||
| if (!body || body.locked) return; | ||||||||||||||||||
| void body.cancel().catch(() => { | ||||||||||||||||||
| /* ignore cancellation failures on discarded bodies */ | ||||||||||||||||||
| }); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| type ResponseWithVinextStreamingMetadata = Response & { | ||||||||||||||||||
| __vinextStreamedHtmlResponse?: boolean; | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| function isVinextStreamedHtmlResponse(response: Response): boolean { | ||||||||||||||||||
| return (response as ResponseWithVinextStreamingMetadata).__vinextStreamedHtmlResponse === true; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Merge middleware/config headers and an optional status override into a new | ||||||||||||||||||
| * Web Response while preserving the original body stream when allowed. | ||||||||||||||||||
| * Keep this in sync with server/worker-utils.ts and the generated copy in | ||||||||||||||||||
| * deploy.ts. | ||||||||||||||||||
| */ | ||||||||||||||||||
| function mergeWebResponse( | ||||||||||||||||||
| middlewareHeaders: Record<string, string | string[]>, | ||||||||||||||||||
| response: Response, | ||||||||||||||||||
| statusOverride?: number, | ||||||||||||||||||
| ): Response { | ||||||||||||||||||
| const status = statusOverride ?? response.status; | ||||||||||||||||||
| const mergedHeaders = mergeResponseHeaders(middlewareHeaders, response); | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
if (isContentLengthHeader(k)) continue;This means Consider either:
This would also let the early-return check at line 227 align with the |
||||||||||||||||||
| const shouldDropBody = isNoBodyResponseStatus(status); | ||||||||||||||||||
| const shouldStripStreamLength = | ||||||||||||||||||
| isVinextStreamedHtmlResponse(response) && hasHeader(mergedHeaders, "content-length"); | ||||||||||||||||||
|
|
||||||||||||||||||
| if ( | ||||||||||||||||||
| !Object.keys(middlewareHeaders).length && | ||||||||||||||||||
| statusOverride === undefined && | ||||||||||||||||||
| !shouldDropBody && | ||||||||||||||||||
| !shouldStripStreamLength | ||||||||||||||||||
| ) { | ||||||||||||||||||
| return response; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (shouldDropBody) { | ||||||||||||||||||
| cancelResponseBody(response); | ||||||||||||||||||
| stripHeaders(mergedHeaders, [ | ||||||||||||||||||
| "content-encoding", | ||||||||||||||||||
| "content-length", | ||||||||||||||||||
| "content-type", | ||||||||||||||||||
| "transfer-encoding", | ||||||||||||||||||
| ]); | ||||||||||||||||||
| return new Response(null, { | ||||||||||||||||||
| status, | ||||||||||||||||||
| statusText: status === response.status ? response.statusText : undefined, | ||||||||||||||||||
| headers: toWebHeaders(mergedHeaders), | ||||||||||||||||||
| }); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (shouldStripStreamLength) { | ||||||||||||||||||
| stripHeaders(mergedHeaders, ["content-length"]); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| return new Response(response.body, { | ||||||||||||||||||
| status, | ||||||||||||||||||
| statusText: status === response.status ? response.statusText : undefined, | ||||||||||||||||||
| headers: toWebHeaders(mergedHeaders), | ||||||||||||||||||
| }); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Send a compressed response if the content type is compressible and the | ||||||||||||||||||
| * client supports compression. Otherwise send uncompressed. | ||||||||||||||||||
|
|
@@ -174,6 +275,13 @@ function sendCompressed( | |||||||||||||||||
| const buf = typeof body === "string" ? Buffer.from(body) : body; | ||||||||||||||||||
| const baseType = contentType.split(";")[0].trim(); | ||||||||||||||||||
| const encoding = compress ? negotiateEncoding(req) : null; | ||||||||||||||||||
| const { | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good change -- stripping both |
||||||||||||||||||
| "content-length": _cl, | ||||||||||||||||||
| "Content-Length": _CL, | ||||||||||||||||||
| "content-type": _ct, | ||||||||||||||||||
| "Content-Type": _CT, | ||||||||||||||||||
| ...headersWithoutBodyHeaders | ||||||||||||||||||
| } = extraHeaders; | ||||||||||||||||||
|
Comment on lines
+278
to
+284
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good change -- stripping both Note that this uses exact-case destructuring ( |
||||||||||||||||||
|
|
||||||||||||||||||
| const writeHead = (headers: Record<string, string | string[]>) => { | ||||||||||||||||||
| if (statusText) { | ||||||||||||||||||
|
|
@@ -200,7 +308,7 @@ function sendCompressed( | |||||||||||||||||
| varyValue = "Accept-Encoding"; | ||||||||||||||||||
| } | ||||||||||||||||||
| writeHead({ | ||||||||||||||||||
| ...extraHeaders, | ||||||||||||||||||
| ...headersWithoutBodyHeaders, | ||||||||||||||||||
| "Content-Type": contentType, | ||||||||||||||||||
| "Content-Encoding": encoding, | ||||||||||||||||||
| Vary: varyValue, | ||||||||||||||||||
|
|
@@ -210,11 +318,8 @@ function sendCompressed( | |||||||||||||||||
| /* ignore pipeline errors on closed connections */ | ||||||||||||||||||
| }); | ||||||||||||||||||
| } else { | ||||||||||||||||||
| // Strip any pre-existing content-length (from the Web Response constructor) | ||||||||||||||||||
| // before setting our own — avoids duplicate Content-Length headers. | ||||||||||||||||||
| const { "content-length": _cl, "Content-Length": _CL, ...headersWithoutLength } = extraHeaders; | ||||||||||||||||||
| writeHead({ | ||||||||||||||||||
| ...headersWithoutLength, | ||||||||||||||||||
| ...headersWithoutBodyHeaders, | ||||||||||||||||||
| "Content-Type": contentType, | ||||||||||||||||||
| "Content-Length": String(buf.length), | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
@@ -494,6 +599,7 @@ async function sendWebResponse( | |||||||||||||||||
|
|
||||||||||||||||||
| // HEAD requests: send headers only, skip the body | ||||||||||||||||||
| if (req.method === "HEAD") { | ||||||||||||||||||
| cancelResponseBody(webResponse); | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good fix -- this addresses the HEAD request body leak that the previous review flagged. The SSR render stream is now properly canceled instead of left running with no downstream reader. One subtlety: |
||||||||||||||||||
| res.end(); | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
@@ -1180,23 +1286,31 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { | |||||||||||||||||
| response = new Response("404 - API route not found", { status: 404 }); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Merge middleware + config headers into the response | ||||||||||||||||||
| const responseBody = Buffer.from(await response.arrayBuffer()); | ||||||||||||||||||
| const mergedResponse = mergeWebResponse( | ||||||||||||||||||
| middlewareHeaders, | ||||||||||||||||||
| response, | ||||||||||||||||||
| middlewareRewriteStatus, | ||||||||||||||||||
| ); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!mergedResponse.body) { | ||||||||||||||||||
| await sendWebResponse(mergedResponse, req, res, compress); | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const responseBody = Buffer.from(await mergedResponse.arrayBuffer()); | ||||||||||||||||||
| // API routes may return arbitrary data (JSON, binary, etc.), so | ||||||||||||||||||
| // default to application/octet-stream rather than text/html when | ||||||||||||||||||
| // the handler doesn't set an explicit Content-Type. | ||||||||||||||||||
| const ct = response.headers.get("content-type") ?? "application/octet-stream"; | ||||||||||||||||||
| const responseHeaders = mergeResponseHeaders(middlewareHeaders, response); | ||||||||||||||||||
| const finalStatus = middlewareRewriteStatus ?? response.status; | ||||||||||||||||||
| const finalStatusText = | ||||||||||||||||||
| finalStatus === response.status ? response.statusText || undefined : undefined; | ||||||||||||||||||
| const ct = mergedResponse.headers.get("content-type") ?? "application/octet-stream"; | ||||||||||||||||||
| const responseHeaders = mergeResponseHeaders({}, mergedResponse); | ||||||||||||||||||
| const finalStatusText = mergedResponse.statusText || undefined; | ||||||||||||||||||
|
|
||||||||||||||||||
| sendCompressed( | ||||||||||||||||||
| req, | ||||||||||||||||||
| res, | ||||||||||||||||||
| responseBody, | ||||||||||||||||||
| ct, | ||||||||||||||||||
| finalStatus, | ||||||||||||||||||
| mergedResponse.status, | ||||||||||||||||||
| responseHeaders, | ||||||||||||||||||
| compress, | ||||||||||||||||||
| finalStatusText, | ||||||||||||||||||
|
|
@@ -1247,20 +1361,25 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { | |||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Merge middleware + config headers into the response | ||||||||||||||||||
| const responseBody = Buffer.from(await response.arrayBuffer()); | ||||||||||||||||||
| const ct = response.headers.get("content-type") ?? "text/html"; | ||||||||||||||||||
| const responseHeaders = mergeResponseHeaders(middlewareHeaders, response); | ||||||||||||||||||
| const finalStatus = middlewareRewriteStatus ?? response.status; | ||||||||||||||||||
| const finalStatusText = | ||||||||||||||||||
| finalStatus === response.status ? response.statusText || undefined : undefined; | ||||||||||||||||||
| const shouldStreamPagesResponse = isVinextStreamedHtmlResponse(response); | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The ordering here is critical and correct --
Suggested change
|
||||||||||||||||||
| const mergedResponse = mergeWebResponse(middlewareHeaders, response, middlewareRewriteStatus); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (shouldStreamPagesResponse || !mergedResponse.body) { | ||||||||||||||||||
| await sendWebResponse(mergedResponse, req, res, compress); | ||||||||||||||||||
|
Comment on lines
+1367
to
+1368
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Routing tagged Pages responses through Useful? React with 👍 / 👎.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. HEAD requests will leak the SSR render stream. With this change, streamed Pages responses now always route through This is a pre-existing gap in |
||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const responseBody = Buffer.from(await mergedResponse.arrayBuffer()); | ||||||||||||||||||
| const ct = mergedResponse.headers.get("content-type") ?? "text/html"; | ||||||||||||||||||
| const responseHeaders = mergeResponseHeaders({}, mergedResponse); | ||||||||||||||||||
| const finalStatusText = mergedResponse.statusText || undefined; | ||||||||||||||||||
|
|
||||||||||||||||||
| sendCompressed( | ||||||||||||||||||
| req, | ||||||||||||||||||
| res, | ||||||||||||||||||
| responseBody, | ||||||||||||||||||
| ct, | ||||||||||||||||||
| finalStatus, | ||||||||||||||||||
| mergedResponse.status, | ||||||||||||||||||
| responseHeaders, | ||||||||||||||||||
| compress, | ||||||||||||||||||
| finalStatusText, | ||||||||||||||||||
|
|
@@ -1291,6 +1410,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { | |||||||||||||||||
| // Export helpers for testing | ||||||||||||||||||
| export { | ||||||||||||||||||
| sendCompressed, | ||||||||||||||||||
| sendWebResponse, | ||||||||||||||||||
| negotiateEncoding, | ||||||||||||||||||
| COMPRESSIBLE_TYPES, | ||||||||||||||||||
| COMPRESS_THRESHOLD, | ||||||||||||||||||
|
|
@@ -1299,4 +1419,5 @@ export { | |||||||||||||||||
| trustProxy, | ||||||||||||||||||
| nodeToWebRequest, | ||||||||||||||||||
| mergeResponseHeaders, | ||||||||||||||||||
| mergeWebResponse, | ||||||||||||||||||
| }; | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: In the deploy.ts template,
response.__vinextStreamedHtmlResponseis accessed without a type cast (unlikeworker-utils.tsandprod-server.tswhich use(response as ResponseWithVinextStreamingMetadata).__vinextStreamedHtmlResponse). This works because the template is emitted as plain JS, but the inconsistency is slightly surprising when reading the three implementations side-by-side. A comment noting this is intentional (template JS, no TS narrowing needed) would help future readers.