Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 140 additions & 1 deletion apps/docs/scripts/test-header-merge-e2e.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { createServer, get, type IncomingMessage, type ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { dirname } from 'node:path'
import { pathToFileURL } from 'node:url'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { toRecord } from '@sim/utils/object'
import { NodeNextRequest, NodeNextResponse } from 'next/dist/server/base-http/node'
import { patchSetHeaderWithCookieSupport } from 'next/dist/server/lib/patch-set-header'
import { addRequestMeta } from 'next/dist/server/request-meta'
import { sendResponse } from 'next/dist/server/send-response'

const logger = createLogger('DocsHeaderMergeE2E')
const reportPath = process.env.DOCS_HEADER_REPORT_PATH
Expand Down Expand Up @@ -80,11 +82,66 @@ const cases: HeaderCase[] = [
},
]

interface FramingCase {
name: string
body: string
headers: Record<string, string>
expected: { 'content-length'?: string; 'content-encoding'?: string }
forbidden?: string[]
}

const framingBody = 'route handler body'
const framingCases: FramingCase[] = [
{
name: 'an unencoded body keeps its declared length',
body: framingBody,
headers: { 'Content-Length': String(framingBody.length) },
expected: { 'content-length': String(framingBody.length) },
},
{
name: 'an identity coding keeps its length and coding',
body: framingBody,
headers: { 'Content-Encoding': 'identity', 'Content-Length': String(framingBody.length) },
expected: { 'content-length': String(framingBody.length), 'content-encoding': 'identity' },
},
{
name: 'a streaming response keeps its no-compression marker',
body: framingBody,
headers: { 'Content-Encoding': 'none' },
expected: { 'content-encoding': 'none' },
},
{
name: 'a transparently decoded body drops its stale length and coding',
body: framingBody,
headers: { 'Content-Encoding': 'gzip', 'Content-Length': '7' },
expected: {},
forbidden: ['content-length', 'content-encoding'],
},
{
name: 'a declared transfer coding is never forwarded',
body: framingBody,
headers: { 'Transfer-Encoding': 'gzip, chunked' },
expected: {},
},
]

const require = createRequire(import.meta.url)
const esmPath = require.resolve('next/dist/esm/server/lib/patch-set-header.js')
const imported: unknown = await import(pathToFileURL(esmPath).href)
const esmPatch = toRecord(imported).patchSetHeaderWithCookieSupport
if (typeof esmPatch !== 'function') throw new Error('The ESM header merger is unavailable')
const esmSendPath = require.resolve('next/dist/esm/server/send-response.js')
const esmSendResponse = toRecord(await import(pathToFileURL(esmSendPath).href)).sendResponse
if (typeof esmSendResponse !== 'function') throw new Error('The ESM response sender is unavailable')
const senders: Array<{ name: string; send: typeof sendResponse }> = [
{ name: 'CommonJS', send: sendResponse },
{
name: 'ESM',
send: async (req, res, response) => {
await esmSendResponse(req, res, response)
},
},
]

const variants: Array<{
name: string
Expand Down Expand Up @@ -187,6 +244,88 @@ try {
)
}
}
for (const sender of senders) {
const server = createServer((req, res) => {
const entry = framingCases[Number(req.url?.slice(1))]
if (!entry) {
res.writeHead(404).end()
return
}
sender
.send(
new NodeNextRequest(req),
new NodeNextResponse(res),
new Response(entry.body, { headers: entry.headers })
)
.catch((error: unknown) => {
res.statusCode = 500
res.end(getErrorMessage(error))
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
try {
const address = server.address()
if (!address || typeof address === 'string')
throw new Error('No loopback listener is available')
for (const [index, entry] of framingCases.entries()) {
const started = performance.now()
let error: string | undefined
try {
const { status, headers, body } = await new Promise<{
status: number
headers: IncomingMessage['headers']
body: string
}>((resolve, reject) => {
const request = get(
{ host: '127.0.0.1', port: address.port, path: `/${index}`, timeout: 5000 },
(response) => {
const chunks: Buffer[] = []
response.on('data', (chunk: Buffer) => chunks.push(chunk))
response.on('end', () =>
resolve({
status: response.statusCode ?? 0,
headers: response.headers,
body: Buffer.concat(chunks).toString('utf8'),
})
)
response.on('error', reject)
}
)
request.on('timeout', () => request.destroy(new Error('Request timed out')))
request.on('error', reject)
})
if (status !== 200 || body !== entry.body)
throw new Error(`Unexpected response: ${status} ${JSON.stringify(body)}`)
for (const [name, value] of Object.entries(entry.expected))
if (headers[name] !== value)
throw new Error(`Expected ${name} ${value}, got ${String(headers[name])}`)
for (const name of entry.forbidden ?? [])
if (headers[name] !== undefined)
throw new Error(`Stale ${name} was forwarded: ${String(headers[name])}`)
if (
entry.headers['Transfer-Encoding'] &&
headers['transfer-encoding'] === 'gzip, chunked'
)
throw new Error('The declared transfer coding was forwarded')
} catch (caught) {
error = getErrorMessage(caught)
}
checks.push({
name: `${sender.name}: ${entry.name}`,
status: error ? 'failed' : 'passed',
durationMs: Math.round(performance.now() - started),
...(error ? { error } : {}),
})
}
} finally {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
)
}
}
} catch (error) {
checks.push({
name: 'Header suite lifecycle',
Expand Down
30 changes: 30 additions & 0 deletions patches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,36 @@ bun run --cwd apps/docs test:negotiation:e2e
Remove the patch when upgrading to a release with equivalent Vary preservation,
and retain the HTTP regression check.

# Next.js route-handler response framing

`next@16.4.5` (GHSA-pc58-hwjq-p385) drops `Content-Length`, `Content-Encoding`,
and `Transfer-Encoding` from every Node route-handler response. The concern is a
fetched upstream response returned unchanged: fetch decodes its body but keeps
the original coding and length, so forwarding them frames a body that was never
sent. Dropping the headers from every response also strips the declared length
from file downloads, which then arrive chunked with no size, and the
`Content-Encoding: none` marker that keeps streaming responses uncompressed.

The same file's patch narrows that rule in `sendResponse`: the CommonJS and ESM
module builds, and the copy prebundled into
`compiled/next-server/server.runtime.prod.js`, which `next start` runs. A response whose `Content-Encoding` names a real coding keeps
upstream's behavior: its length and coding are dropped. A response with no coding,
or with `identity`/`none`, was not decoded, so its `Content-Length` and coding are
forwarded. `Transfer-Encoding` is never forwarded; Node frames the response
itself. Route handlers, App Page rendering, and the edge runtime are otherwise
unchanged.

`test:headers:e2e` sends real responses through both module builds of
`sendResponse` over loopback HTTP. The unencoded, `identity`, and `none` checks fail against
unpatched 16.4.5. The decoded-body and transfer-coding checks fail when their
guards are removed; forwarding a decoded body's stale length leaves the client
unable to parse the connection. The project-files E2E's large-download check
covers the production runtime: it asserts the declared length of a 100 MB file
served by the built app.

Remove this part of the patch when upstream forwards framing for undecoded
route-handler bodies, and keep both regression checks.

# Zod collection size bounds

`zod@4.3.6` runs an array's `.max()`/`.length()` (and a set's or map's `.max()`/`.size()`)
Expand Down
Loading
Loading