Skip to content

Commit 7589537

Browse files
authored
fix(docs): preserve negotiated response cache headers (#9043)
* fix(docs): preserve negotiated response cache headers * fix(docs): retain native header regression checks
1 parent b958bfc commit 7589537

8 files changed

Lines changed: 476 additions & 1 deletion

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: docs content negotiation
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'apps/docs/**'
7+
- 'patches/next@*.patch'
8+
- 'package.json'
9+
- 'bun.lock'
10+
- '.github/workflows/docs-negotiation.yml'
11+
push:
12+
branches: [staging]
13+
paths:
14+
- 'apps/docs/**'
15+
- 'patches/next@*.patch'
16+
- 'package.json'
17+
- 'bun.lock'
18+
- '.github/workflows/docs-negotiation.yml'
19+
workflow_dispatch:
20+
21+
permissions:
22+
contents: read
23+
24+
concurrency:
25+
group: docs-negotiation-${{ github.ref }}
26+
cancel-in-progress: true
27+
28+
jobs:
29+
http:
30+
name: docs HTTP
31+
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'linux-x64-8-core' }}
32+
timeout-minutes: 15
33+
env:
34+
DATABASE_URL: postgresql://docs_test:docs_test@127.0.0.1:1/docs_test
35+
DOCS_E2E_BASE_URL: http://127.0.0.1:3001
36+
DOCS_NEGOTIATION_REPORT_PATH: ${{ github.workspace }}/test-results/docs-negotiation.json
37+
DOCS_HEADER_REPORT_PATH: ${{ github.workspace }}/test-results/docs-headers.json
38+
steps:
39+
- name: Checkout code
40+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
41+
- name: Setup workspace
42+
uses: ./.github/actions/setup
43+
with:
44+
provider: ${{ vars.CI_PROVIDER }}
45+
- name: Build production docs
46+
run: bun run --cwd apps/docs build
47+
- name: Verify native header edge cases
48+
run: bun run --cwd apps/docs test:headers:e2e
49+
- name: Verify HTTP content negotiation
50+
run: |
51+
mkdir -p test-results
52+
bun run --cwd apps/docs start --port 3001 >test-results/docs-server.log 2>&1 &
53+
DOCS_SERVER_PID=$!
54+
trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true' EXIT
55+
bun run --cwd apps/docs test:negotiation:e2e
56+
- name: Upload HTTP regression report
57+
if: always()
58+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
59+
with:
60+
name: docs-negotiation-report
61+
path: test-results/
62+
if-no-files-found: warn
63+
retention-days: 14

‎apps/docs/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"start": "next start",
1212
"postinstall": "fumadocs-mdx",
1313
"test": "vitest run",
14+
"test:negotiation:e2e": "bun scripts/test-negotiation-e2e.ts",
15+
"test:headers:e2e": "bun scripts/test-header-merge-e2e.ts",
1416
"type-check": "fumadocs-mdx && tsc --noEmit",
1517
"lint": "biome check --write --unsafe .",
1618
"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 })
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { mkdir, writeFile } from 'node:fs/promises'
2+
import { dirname } from 'node:path'
3+
import { createLogger } from '@sim/logger'
4+
import { getErrorMessage } from '@sim/utils/errors'
5+
import { sleep } from '@sim/utils/helpers'
6+
7+
const logger = createLogger('DocsNegotiationE2E')
8+
const origin = process.env.DOCS_E2E_BASE_URL
9+
const reportPath = process.env.DOCS_NEGOTIATION_REPORT_PATH
10+
if (!origin || !reportPath) {
11+
throw new Error('DOCS_E2E_BASE_URL and DOCS_NEGOTIATION_REPORT_PATH are required')
12+
}
13+
const url = new URL('/introduction', origin)
14+
if (!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
15+
throw new Error('The documentation server must use a loopback origin')
16+
}
17+
18+
interface CheckResult {
19+
name: string
20+
status: 'passed' | 'failed'
21+
durationMs: number
22+
contentType?: string
23+
vary?: string
24+
error?: string
25+
}
26+
27+
const cases = [
28+
{ name: 'HTML advertises its representation and router cache dimensions', accept: 'text/html' },
29+
{ name: 'Markdown negotiates at the same URL', accept: 'text/markdown' },
30+
{ name: 'HTML remains available after a Markdown request', accept: 'text/html' },
31+
] as const
32+
33+
for (let attempt = 0; attempt < 20; attempt += 1) {
34+
try {
35+
const response = await fetch(url, { signal: AbortSignal.timeout(1000) })
36+
await response.body?.cancel()
37+
if (response.ok) break
38+
} catch {}
39+
await sleep(250)
40+
}
41+
42+
const checks: CheckResult[] = []
43+
for (const entry of cases) {
44+
const started = performance.now()
45+
const result: CheckResult = { name: entry.name, status: 'failed', durationMs: 0 }
46+
try {
47+
const response = await fetch(url, {
48+
headers: { Accept: entry.accept },
49+
signal: AbortSignal.timeout(10000),
50+
})
51+
await response.body?.cancel()
52+
result.contentType = response.headers.get('Content-Type') ?? ''
53+
result.vary = response.headers.get('Vary') ?? ''
54+
const fields = result.vary.split(',').map((field) => field.trim().toLowerCase())
55+
if (!response.ok) throw new Error(`Unexpected HTTP status ${response.status}`)
56+
if (!result.contentType.startsWith(entry.accept)) {
57+
throw new Error(`Expected ${entry.accept}; received ${result.contentType}`)
58+
}
59+
if (!fields.includes('accept')) throw new Error('The response lost Vary: Accept')
60+
if (entry.accept === 'text/html') {
61+
for (const field of [
62+
'rsc',
63+
'next-router-state-tree',
64+
'next-router-prefetch',
65+
'next-router-segment-prefetch',
66+
]) {
67+
if (!fields.includes(field)) throw new Error(`The response lost Vary: ${field}`)
68+
}
69+
}
70+
if (fields.length !== new Set(fields).size) throw new Error('Vary contains duplicate fields')
71+
result.status = 'passed'
72+
} catch (error) {
73+
result.error = getErrorMessage(error)
74+
}
75+
result.durationMs = Math.round(performance.now() - started)
76+
checks.push(result)
77+
}
78+
79+
await mkdir(dirname(reportPath), { recursive: true })
80+
await writeFile(reportPath, `${JSON.stringify({ checks }, null, 2)}\n`)
81+
const failures = checks.filter((check) => check.status === 'failed')
82+
if (failures.length) {
83+
logger.error('Documentation negotiation failed', { failures })
84+
process.exitCode = 1
85+
} else {
86+
logger.info('Documentation negotiation passed', { checks: checks.length })
87+
}

‎bun.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@
212212
"drizzle-kit@0.31.10": "patches/drizzle-kit@0.31.10.patch",
213213
"postgres@3.4.9": "patches/postgres@3.4.9.patch",
214214
"oracledb@7.0.1": "patches/oracledb@7.0.1.patch",
215-
"echarts@6.1.0": "patches/echarts@6.1.0.patch"
215+
"echarts@6.1.0": "patches/echarts@6.1.0.patch",
216+
"next@16.4.0": "patches/next@16.4.0.patch"
216217
}
217218
}

0 commit comments

Comments
 (0)