Files
next.js/test/e2e/app-dir/segment-cache/cdn-cache-busting/server.mjs
Andrew ClarkandGitHub f75598fe43 Replay redirect if RSC parameter is missing (#80180)
If the server responds with a redirect (e.g. 307), and the redirected
location does not contain the cache busting search param set in the
original request, the response is likely invalid — when following the
redirect, the browser will have forwarded the request headers, but
without a corresponding cache busting search param. (See
https://github.com/vercel/next.js/pull/79563 for more context.)

Ideally, we would be able to intercept the redirect response and perform
it manually, instead of letting the browser automatically follow it, but
this is not allowed by the fetch API.

So instead, we must "replay" the redirect by fetching the new location
again, but this time we'll append the cache busting search param to
prevent a mismatch.

We can optimize Next.js's built-in middleware APIs by returning a custom
status code, to prevent the browser from automatically following it.
This will land in future PRs. Third-party proxies can choose to
implement this same protocol for cases where they need to redirect
Next.js requests but are not using Next.js APIs.

However, we'll still need this fallback behavior for third-party proxies
that don't implement our optimized redirect protocol.

This does not affect Server Action-based redirects; those are encoded
differently, as part of the Flight body.

As of now, this is gated behind the experimental
`validateRSCRequestHeaders` flag. We will test it before turning it on
by default.
2025-06-05 23:54:16 +00:00

173 lines
5.0 KiB
JavaScript

import { fileURLToPath } from 'url'
import { dirname } from 'path'
import { spawn } from 'child_process'
import { createServer } from 'node:http'
import httpProxy from 'http-proxy'
import process from 'node:process'
import { createGunzip } from 'zlib'
const dir = dirname(fileURLToPath(import.meta.url))
// Redirects that happen in the proxy layer, rather than in Next.js itself. This
// is used to test that the client is still able to fully prefetch the
// target page.
const proxyRedirects = {
'/redirect-to-target-page': '/target-page',
}
async function spawnNext(port) {
const child = spawn('pnpm', ['next', 'start', '-p', port, dir], {
env: process.env,
stdio: ['inherit', 'pipe', 'inherit'],
})
child.stdout.pipe(process.stdout)
// Wait until the server is listening.
return new Promise((resolve, reject) => {
child.stdout.on('data', (data) => {
if (data.toString().includes('Ready')) {
resolve(child)
}
})
child.on('exit', (code) => {
if (code === 0) {
resolve(child)
} else {
reject(new Error(`Next.js server exited with code ${code}`))
}
})
})
}
function isCacheableRequest(req) {
return (
req.method === 'GET' && !req.headers['cache-control']?.includes('no-store')
)
}
function isCacheableResponse(res) {
return !res.headers['cache-control']?.includes('no-store')
}
async function createFakeCDN(destPort) {
const fakeCDNCache = new Map()
const proxy = httpProxy.createProxyServer()
const cdnServer = createServer(async (req, res) => {
const pathname = new URL(req.url, `http://localhost`).pathname
const redirectUrl = proxyRedirects[pathname]
if (redirectUrl) {
console.log('Redirecting to:', redirectUrl)
res.writeHead(307, {
Location: redirectUrl,
})
res.end()
return
}
if (isCacheableRequest(req)) {
// Serve from our fake CDN if there's a matching entry.
const entry = await fakeCDNCache.get(req.url)
if (entry) {
console.log('Serving from fake CDN:', req.url)
res.writeHead(entry.statusCode, entry.statusMessage, entry.headers)
res.end(entry.data)
return
} else {
// No existing entry. Proxy to the Next.js server and then store
// the response in the cache.
proxy.web(req, res, {
target: `http://localhost:${destPort}`,
selfHandleResponse: true,
})
}
} else {
// This request isn't cacheable.
proxy.web(req, res, { target: `http://localhost:${destPort}` })
}
})
proxy.on('proxyRes', function (proxyRes, req, res) {
// If the response is cacheable, store it in a map to simulate a CDN.
//
// Note that we only key the entry on the URL, not any of the headers. i.e.
// we don't respect the Vary header. This is true of certain real CDNs, so
// Next.js must not rely on Vary.
//
// For the purposes of this test we don't respect max-age et al. Every
// entry is cached indefinitely.
if (isCacheableRequest(req) && isCacheableResponse(proxyRes)) {
let resolveCDNEntry
fakeCDNCache.set(req.url, new Promise((res) => (resolveCDNEntry = res)))
// Decompress the original response stream, if needed
let source
if (proxyRes.headers['content-encoding'] === 'gzip') {
source = proxyRes.pipe(createGunzip())
// Just store the uncompressed body and serve that from cache.
// Good enough for the purposes of this test app.
delete proxyRes.headers['content-encoding']
} else {
source = proxyRes
}
const chunks = []
source.on('data', (chunk) => {
chunks.push(chunk)
})
source.on('end', () => {
const data = Buffer.concat(chunks)
// Send response after we've collected all chunks
res.writeHead(
proxyRes.statusCode || 200,
proxyRes.statusMessage,
proxyRes.headers
)
res.end(data)
// Store the raw data for later use
const entry = {
data,
statusCode: proxyRes.statusCode,
statusMessage: proxyRes.statusMessage,
headers: proxyRes.headers,
}
resolveCDNEntry(entry)
})
return
}
// If the response isn't cacheable, pipe it through to the client.
proxyRes.pipe(res)
return
})
return cdnServer
}
export async function start(cdnPort = 3000, nextPort = cdnPort + 1) {
const next = await spawnNext(nextPort)
const cdnServer = await createFakeCDN(nextPort)
const onTerminate = () => {
cdnServer.close()
next.kill()
process.exit(0)
}
process.on('SIGINT', onTerminate)
process.on('SIGTERM', onTerminate)
const cleanup = async () => {
next.kill()
await new Promise((resolve) => cdnServer.close(resolve))
}
return new Promise((resolve, reject) => {
cdnServer.on('error', reject)
cdnServer.listen(cdnPort, () => {
console.log('Server is listening', cdnPort)
resolve(cleanup)
})
})
}