Skip to content

Commit d6e9920

Browse files
committed
fix(docs): preserve negotiated response cache headers
1 parent b958bfc commit d6e9920

7 files changed

Lines changed: 262 additions & 1 deletion

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
steps:
38+
- name: Checkout code
39+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
40+
- name: Setup workspace
41+
uses: ./.github/actions/setup
42+
with:
43+
provider: ${{ vars.CI_PROVIDER }}
44+
- name: Build production docs
45+
run: bun run --cwd apps/docs build
46+
- name: Verify HTTP content negotiation
47+
run: |
48+
mkdir -p test-results
49+
bun run --cwd apps/docs start --port 3001 >test-results/docs-server.log 2>&1 &
50+
DOCS_SERVER_PID=$!
51+
trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true' EXIT
52+
bun run --cwd apps/docs test:negotiation:e2e
53+
- name: Upload HTTP regression report
54+
if: always()
55+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
56+
with:
57+
name: docs-negotiation-report
58+
path: test-results/
59+
if-no-files-found: warn
60+
retention-days: 14

‎apps/docs/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"start": "next start",
1212
"postinstall": "fumadocs-mdx",
1313
"test": "vitest run",
14+
"test:negotiation:e2e": "bun scripts/test-negotiation-e2e.ts",
1415
"type-check": "fumadocs-mdx && tsc --noEmit",
1516
"lint": "biome check --write --unsafe .",
1617
"lint:check": "biome check .",
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
}

‎patches/README.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,39 @@ rich-text mode, preserving the chart document's untrusted-markup boundary.
8989
`apps/sim/lib/charts/tooltip.test.ts` exercises actual ECharts tooltip rendering,
9090
font overrides, generated tokens, and box sizing. Remove this patch when an
9191
upstream release provides equivalent font handling and these tests pass.
92+
93+
# Next.js application Vary preservation
94+
95+
`next@16.4.0` replaces application `Vary` values when the Node App Page handler
96+
sets its router cache dimensions. The docs proxy's `Vary: Accept` therefore
97+
vanishes from the final HTML response, allowing downstream caches to confuse
98+
HTML with negotiated Markdown.
99+
100+
The pinned patch applies the merging semantics of [upstream PR #98104](https://github.com/vercel/next.js/pull/98104)
101+
at Next's native Node outgoing-header merger, which is used before the bundled
102+
App Page handler. Both published CommonJS and ESM entry points preserve existing
103+
Vary fields and add later fields without case-sensitive duplicates. Wildcards
104+
remain intact, and native header validation still rejects invalid values.
105+
106+
Vary writes become cumulative across Node server responses in both the docs and
107+
Sim workspaces; explicit header removal, cookie handling, other headers, router
108+
selection, cache lifetimes, and authentication are unchanged. Edge SSR and direct
109+
handlers outside Next's Node server are outside this patch's scope.
110+
111+
The `docs content negotiation` CI workflow builds and serves the actual docs,
112+
then runs `apps/docs/scripts/test-negotiation-e2e.ts`. Its HTTP checks verify
113+
HTML → Markdown → HTML at one URL, `Vary: Accept` on both representations, and
114+
preservation of Next's router dimensions. It uploads a JSON report and server
115+
log. The HTML checks fail with the unpatched dependency even when the proxy
116+
already sets `Vary: Accept`.
117+
118+
To repeat against a running production docs server:
119+
120+
```bash
121+
DOCS_E2E_BASE_URL=http://127.0.0.1:3001 \
122+
DOCS_NEGOTIATION_REPORT_PATH=/tmp/docs-negotiation.json \
123+
bun run --cwd apps/docs test:negotiation:e2e
124+
```
125+
126+
Remove the patch when upgrading to a release with equivalent Vary preservation,
127+
and retain the HTTP regression check.

‎patches/next@16.4.0.patch‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
diff --git a/dist/esm/server/lib/patch-set-header.js b/dist/esm/server/lib/patch-set-header.js
2+
index f2236f7ae222afcb8554267ed295ada59dceee6a..3be146fc8fa48196254d93a8a21c2ceac2596b09 100644
3+
--- a/dist/esm/server/lib/patch-set-header.js
4+
+++ b/dist/esm/server/lib/patch-set-header.js
5+
@@ -1,3 +1,4 @@
6+
+import { validateHeaderValue } from 'node:http';
7+
import { getRequestMeta } from '../request-meta';
8+
const PATCHED_SET_HEADER = Symbol('next.patchSetHeaderWithCookieSupport');
9+
/**
10+
@@ -20,6 +21,26 @@ const PATCHED_SET_HEADER = Symbol('next.patchSetHeaderWithCookieSupport');
11+
if ('headersSent' in res && res.headersSent) {
12+
return res;
13+
}
14+
+ if (name.toLowerCase() === 'vary' && (typeof value === 'string' || Array.isArray(value))) {
15+
+ validateHeaderValue(name, value);
16+
+ const fields = [];
17+
+ const seen = new Set();
18+
+ const collect = (candidate)=>{
19+
+ const values = Array.isArray(candidate) ? candidate : typeof candidate === 'string' ? [candidate] : [];
20+
+ for (const entry of values){
21+
+ for (const field of entry.split(',')){
22+
+ const trimmed = field.trim();
23+
+ const normalized = trimmed.toLowerCase();
24+
+ if (!trimmed || seen.has(normalized)) continue;
25+
+ seen.add(normalized);
26+
+ fields.push(trimmed);
27+
+ }
28+
+ }
29+
+ };
30+
+ collect(res.getHeader(name));
31+
+ collect(value);
32+
+ value = seen.has('*') ? '*' : fields.join(', ');
33+
+ }
34+
if (name.toLowerCase() === 'set-cookie') {
35+
const middlewareValue = getRequestMeta(req, 'middlewareCookie');
36+
if (!middlewareValue || !Array.isArray(value) || !value.every((item, idx)=>item === middlewareValue[idx])) {
37+
diff --git a/dist/server/lib/patch-set-header.js b/dist/server/lib/patch-set-header.js
38+
index 16cdc4703f3e9957a16c68c962cfaf5ee5d04561..a1c3c7061bc917284e01ed9947cd914818cfd708 100644
39+
--- a/dist/server/lib/patch-set-header.js
40+
+++ b/dist/server/lib/patch-set-header.js
41+
@@ -8,6 +8,7 @@ Object.defineProperty(exports, "patchSetHeaderWithCookieSupport", {
42+
return patchSetHeaderWithCookieSupport;
43+
}
44+
});
45+
+const _nodehttp = require("node:http");
46+
const _requestmeta = require("../request-meta");
47+
const PATCHED_SET_HEADER = Symbol('next.patchSetHeaderWithCookieSupport');
48+
function patchSetHeaderWithCookieSupport(req, res) {
49+
@@ -24,6 +25,26 @@ function patchSetHeaderWithCookieSupport(req, res) {
50+
if ('headersSent' in res && res.headersSent) {
51+
return res;
52+
}
53+
+ if (name.toLowerCase() === 'vary' && (typeof value === 'string' || Array.isArray(value))) {
54+
+ (0, _nodehttp.validateHeaderValue)(name, value);
55+
+ const fields = [];
56+
+ const seen = new Set();
57+
+ const collect = (candidate)=>{
58+
+ const values = Array.isArray(candidate) ? candidate : typeof candidate === 'string' ? [candidate] : [];
59+
+ for (const entry of values){
60+
+ for (const field of entry.split(',')){
61+
+ const trimmed = field.trim();
62+
+ const normalized = trimmed.toLowerCase();
63+
+ if (!trimmed || seen.has(normalized)) continue;
64+
+ seen.add(normalized);
65+
+ fields.push(trimmed);
66+
+ }
67+
+ }
68+
+ };
69+
+ collect(res.getHeader(name));
70+
+ collect(value);
71+
+ value = seen.has('*') ? '*' : fields.join(', ');
72+
+ }
73+
if (name.toLowerCase() === 'set-cookie') {
74+
const middlewareValue = (0, _requestmeta.getRequestMeta)(req, 'middlewareCookie');
75+
if (!middlewareValue || !Array.isArray(value) || !value.every((item, idx)=>item === middlewareValue[idx])) {

0 commit comments

Comments
 (0)