next.js/test/e2e/app-dir/segment-cache/cdn-cache-busting/server.mjs
server.mjs173 lines5.0 KB
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)
    })
  })
}
Quest for Codev2.0.0
/
SIGN IN