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)
})
})
}