Skip to content

Commit 88f5ac0

Browse files
committed
fix(docs): retain native header regression checks
1 parent d6e9920 commit 88f5ac0

4 files changed

Lines changed: 215 additions & 1 deletion

File tree

‎.github/workflows/docs-negotiation.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ jobs:
3434
DATABASE_URL: postgresql://docs_test:docs_test@127.0.0.1:1/docs_test
3535
DOCS_E2E_BASE_URL: http://127.0.0.1:3001
3636
DOCS_NEGOTIATION_REPORT_PATH: ${{ github.workspace }}/test-results/docs-negotiation.json
37+
DOCS_HEADER_REPORT_PATH: ${{ github.workspace }}/test-results/docs-headers.json
3738
steps:
3839
- name: Checkout code
3940
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
@@ -43,6 +44,8 @@ jobs:
4344
provider: ${{ vars.CI_PROVIDER }}
4445
- name: Build production docs
4546
run: bun run --cwd apps/docs build
47+
- name: Verify native header edge cases
48+
run: bun run --cwd apps/docs test:headers:e2e
4649
- name: Verify HTTP content negotiation
4750
run: |
4851
mkdir -p test-results

‎apps/docs/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"postinstall": "fumadocs-mdx",
1313
"test": "vitest run",
1414
"test:negotiation:e2e": "bun scripts/test-negotiation-e2e.ts",
15+
"test:headers:e2e": "bun scripts/test-header-merge-e2e.ts",
1516
"type-check": "fumadocs-mdx && tsc --noEmit",
1617
"lint": "biome check --write --unsafe .",
1718
"lint:check": "biome check .",
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { mkdir, writeFile } from 'node:fs/promises'
2+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
3+
import { createRequire } from 'node:module'
4+
import { dirname } from 'node:path'
5+
import { pathToFileURL } from 'node:url'
6+
import { createLogger } from '@sim/logger'
7+
import { getErrorMessage } from '@sim/utils/errors'
8+
import { toRecord } from '@sim/utils/object'
9+
import { patchSetHeaderWithCookieSupport } from 'next/dist/server/lib/patch-set-header'
10+
import { addRequestMeta } from 'next/dist/server/request-meta'
11+
12+
const logger = createLogger('DocsHeaderMergeE2E')
13+
const reportPath = process.env.DOCS_HEADER_REPORT_PATH
14+
if (!reportPath) throw new Error('DOCS_HEADER_REPORT_PATH is required')
15+
16+
interface HeaderCase {
17+
name: string
18+
header: string
19+
before?: string | string[]
20+
value: string | string[]
21+
expected?: string[]
22+
rejected?: boolean
23+
middlewareCookies?: string[]
24+
}
25+
26+
const cases: HeaderCase[] = [
27+
{
28+
name: 'existing Vary survives a later write',
29+
header: 'Vary',
30+
before: 'Origin',
31+
value: 'RSC',
32+
expected: ['origin', 'rsc'],
33+
},
34+
{
35+
name: 'array fields survive a later write',
36+
header: 'Vary',
37+
before: ['Accept', 'rSc'],
38+
value: ['RSC', 'Next-Url'],
39+
expected: ['accept', 'rsc', 'next-url'],
40+
},
41+
{
42+
name: 'mixed casing does not duplicate fields',
43+
header: 'Vary',
44+
before: 'Accept, rSc',
45+
value: 'RSC, aCcEpT, Next-Url, RSC',
46+
expected: ['accept', 'rsc', 'next-url'],
47+
},
48+
{
49+
name: 'wildcard remains the only field',
50+
header: 'Vary',
51+
before: '*',
52+
value: 'RSC',
53+
expected: ['*'],
54+
},
55+
{
56+
name: 'invalid bytes cannot be trimmed away',
57+
header: 'Vary',
58+
value: 'RSC\r\n',
59+
rejected: true,
60+
},
61+
{
62+
name: 'wildcard cannot hide invalid array bytes',
63+
header: 'Vary',
64+
value: ['*', 'bad\r\nheader'],
65+
rejected: true,
66+
},
67+
{
68+
name: 'other headers retain replacement behavior',
69+
header: 'X-Probe',
70+
before: 'old',
71+
value: 'new',
72+
expected: ['new'],
73+
},
74+
{
75+
name: 'middleware cookies remain present',
76+
header: 'Set-Cookie',
77+
value: ['probe_b=2'],
78+
middlewareCookies: ['probe_a=1'],
79+
expected: ['probe_a=1', 'probe_b=2'],
80+
},
81+
]
82+
83+
const require = createRequire(import.meta.url)
84+
const esmPath = require.resolve('next/dist/esm/server/lib/patch-set-header.js')
85+
const imported: unknown = await import(pathToFileURL(esmPath).href)
86+
const esmPatch = toRecord(imported).patchSetHeaderWithCookieSupport
87+
if (typeof esmPatch !== 'function') throw new Error('The ESM header merger is unavailable')
88+
89+
const variants: Array<{
90+
name: string
91+
apply: (req: IncomingMessage, res: ServerResponse) => void
92+
}> = [
93+
{ name: 'CommonJS', apply: patchSetHeaderWithCookieSupport },
94+
{
95+
name: 'ESM',
96+
apply: (req, res) => {
97+
esmPatch(req, res)
98+
},
99+
},
100+
]
101+
const checks: Array<{
102+
name: string
103+
status: 'passed' | 'failed'
104+
durationMs: number
105+
error?: string
106+
}> = []
107+
108+
const suiteStarted = performance.now()
109+
try {
110+
for (const variant of variants) {
111+
const server = createServer((req, res) => {
112+
const entry = cases[Number(req.url?.slice(1))]
113+
if (!entry) {
114+
res.writeHead(404).end()
115+
return
116+
}
117+
try {
118+
variant.apply(req, res)
119+
if (entry.middlewareCookies)
120+
addRequestMeta(req, 'middlewareCookie', entry.middlewareCookies)
121+
if (entry.before) res.setHeader(entry.header, entry.before)
122+
} catch (error) {
123+
res.statusCode = 500
124+
res.end(JSON.stringify({ error: getErrorMessage(error) }))
125+
return
126+
}
127+
try {
128+
res.setHeader(entry.header, entry.value)
129+
res.end(JSON.stringify({ accepted: true }))
130+
} catch (error) {
131+
res.statusCode = 400
132+
res.end(JSON.stringify({ rejected: true, code: toRecord(error).code }))
133+
}
134+
})
135+
await new Promise<void>((resolve, reject) => {
136+
server.once('error', reject)
137+
server.listen(0, '127.0.0.1', resolve)
138+
})
139+
try {
140+
const address = server.address()
141+
if (!address || typeof address === 'string')
142+
throw new Error('No loopback listener is available')
143+
for (const [index, entry] of cases.entries()) {
144+
const started = performance.now()
145+
let error: string | undefined
146+
try {
147+
const response = await fetch(`http://127.0.0.1:${address.port}/${index}`, {
148+
signal: AbortSignal.timeout(5000),
149+
})
150+
const payload: unknown = await response.json()
151+
if (entry.rejected) {
152+
const body = toRecord(payload)
153+
if (
154+
response.status !== 400 ||
155+
body.rejected !== true ||
156+
body.code !== 'ERR_INVALID_CHAR'
157+
)
158+
throw new Error('Invalid header bytes were accepted')
159+
} else {
160+
const fields =
161+
entry.header === 'Set-Cookie'
162+
? response.headers.getSetCookie()
163+
: (response.headers.get(entry.header) ?? '')
164+
.split(',')
165+
.map((field) => field.trim().toLowerCase())
166+
const expected = [...(entry.expected ?? [])].sort()
167+
if (
168+
response.status !== 200 ||
169+
new Set(fields).size !== fields.length ||
170+
JSON.stringify([...fields].sort()) !== JSON.stringify(expected)
171+
)
172+
throw new Error(`Unexpected response fields: ${JSON.stringify(fields)}`)
173+
}
174+
} catch (caught) {
175+
error = getErrorMessage(caught)
176+
}
177+
checks.push({
178+
name: `${variant.name}: ${entry.name}`,
179+
status: error ? 'failed' : 'passed',
180+
durationMs: Math.round(performance.now() - started),
181+
...(error ? { error } : {}),
182+
})
183+
}
184+
} finally {
185+
await new Promise<void>((resolve, reject) =>
186+
server.close((error) => (error ? reject(error) : resolve()))
187+
)
188+
}
189+
}
190+
} catch (error) {
191+
checks.push({
192+
name: 'Header suite lifecycle',
193+
status: 'failed',
194+
durationMs: Math.round(performance.now() - suiteStarted),
195+
error: getErrorMessage(error),
196+
})
197+
} finally {
198+
await mkdir(dirname(reportPath), { recursive: true })
199+
await writeFile(reportPath, `${JSON.stringify({ checks }, null, 2)}\n`)
200+
}
201+
202+
const failures = checks.filter((check) => check.status === 'failed')
203+
if (failures.length) {
204+
logger.error('Native header regression checks failed', { failures })
205+
process.exitCode = 1
206+
} else logger.info('Native header regression checks passed', { checks: checks.length })

‎patches/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,11 @@ then runs `apps/docs/scripts/test-negotiation-e2e.ts`. Its HTTP checks verify
113113
HTML → Markdown → HTML at one URL, `Vary: Accept` on both representations, and
114114
preservation of Next's router dimensions. It uploads a JSON report and server
115115
log. The HTML checks fail with the unpatched dependency even when the proxy
116-
already sets `Vary: Accept`.
116+
already sets `Vary: Accept`. `test:headers:e2e` exercises the actual CommonJS and
117+
ESM mergers over loopback HTTP for string/array fields, mixed casing, wildcards,
118+
invalid values, other-header replacement, and middleware cookies. Its checks
119+
fail independently when the merge or validation guard is removed. CI uploads
120+
both reports.
117121

118122
To repeat against a running production docs server:
119123

0 commit comments

Comments
 (0)