next.js/test/production/standalone-mode/required-server-files/required-server-files-ppr.test.ts
required-server-files-ppr.test.ts645 lines20.2 KB
import fs from 'node:fs/promises'
import { join } from 'node:path'
import cheerio from 'cheerio'
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'e2e-utils'
import {
  createNowRouteMatches,
  fetchViaHTTP,
  findPort,
  initNextServerScript,
  killApp,
  retry,
  withInvocationId,
} from 'next-test-utils'
import { ChildProcess } from 'node:child_process'

// TODO(NAR-423): Migrate to Cache Components.
describe.skip('required server files app router', () => {
  let next: NextInstance
  let server: ChildProcess
  let appPort: number | string
  let delayedPostpone: string
  let rewritePostpone: string
  let secondCookiePostpone: string
  let secondCookieHTML: string
  let cliOutput = ''

  beforeAll(async () => {
    process.env.NOW_BUILDER = '1'
    process.env.NEXT_PRIVATE_TEST_HEADERS = '1'
    process.env.NEXT_PRIVATE_DEBUG_CACHE_ENTRY_HANDLERS =
      './cache-entry-handlers.js'

    // Setup the Next.js app and build it.
    next = await createNext({
      files: {
        app: new FileRef(join(__dirname, 'app')),
        'pages/catch-all/[[...rest]].js': new FileRef(
          join(__dirname, 'pages', 'catch-all', '[[...rest]].js')
        ),
        lib: new FileRef(join(__dirname, 'lib')),
        'cache-handler.js': new FileRef(join(__dirname, 'cache-handler.js')),
        'middleware.js': new FileRef(join(__dirname, 'middleware.js')),
        'data.txt': new FileRef(join(__dirname, 'data.txt')),
        '.env': new FileRef(join(__dirname, '.env')),
        '.env.local': new FileRef(join(__dirname, '.env.local')),
        '.env.production': new FileRef(join(__dirname, '.env.production')),
        'cache-entry-handlers.js': new FileRef(
          join(__dirname, 'cache-entry-handlers.js')
        ),
      },
      overrideFiles: {
        'app/not-found.js': new FileRef(
          join(__dirname, 'ppr', 'app', 'not-found.js')
        ),
      },
      nextConfig: {
        cacheHandler: './cache-handler.js',
        cacheComponents: true,
        output: 'standalone',
      },
    })

    // Stop the server, we're going to restart it using the standalone server
    // below after some cleanup.
    await next.stop()

    // Read the postponed state and the HTML that was generated at build time
    // from the output of the build.
    delayedPostpone = (await next.readJSON('.next/server/app/delayed.meta'))
      .postponed
    rewritePostpone = (
      await next.readJSON('.next/server/app/rewrite/first-cookie.meta')
    ).postponed
    secondCookiePostpone = (
      await next.readJSON('.next/server/app/rewrite/second-cookie.meta')
    ).postponed
    secondCookieHTML = await next.readFile(
      '.next/server/app/rewrite/second-cookie.html'
    )

    await fs.rename(
      join(next.testDir, '.next/standalone'),
      join(next.testDir, 'standalone')
    )

    const serverFilePath = join(next.testDir, 'standalone/server.js')

    // We're going to use the minimal mode for the server.
    await fs.writeFile(
      serverFilePath,
      (await fs.readFile(serverFilePath, 'utf8')).replace(
        'port:',
        `minimalMode: true, port:`
      )
    )

    // Find a port to use for the server.
    appPort = await findPort()

    // Then we can start the server with the new environment variables.
    server = await initNextServerScript(
      serverFilePath,
      /- Local:/,
      {
        ...process.env,
        ...next.env,
        __NEXT_TEST_MODE: 'e2e',
        PORT: `${appPort}`,
        NEXT_PRIVATE_DEBUG_CACHE: '1',
      },
      undefined,
      {
        cwd: next.testDir,
        onStderr(data) {
          cliOutput += data
        },
        onStdout(data) {
          cliOutput += data
        },
      }
    )
  })

  afterAll(async () => {
    delete process.env.NOW_BUILDER
    delete process.env.NEXT_PRIVATE_TEST_HEADERS
    delete process.env.NEXT_PRIVATE_DEBUG_CACHE_ENTRY_HANDLERS
    await next.destroy()
    if (server) await killApp(server)
  })

  it('should not fail caching', async () => {
    expect(next.cliOutput).not.toContain('ERR_INVALID_URL')
  })

  it('should de-dupe client segment tree revalidate requests', async () => {
    const { segmentPaths } = await next.readJSON(
      'standalone/.next/server/app/isr/first.meta'
    )
    const outputIdx = cliOutput.length

    for (const segmentPath of segmentPaths) {
      const outputSegmentPath =
        join('/isr/[slug].segments', segmentPath) + '.segment.rsc'

      require('console').error('requesting', outputSegmentPath)

      const res = await fetchViaHTTP(
        appPort,
        outputSegmentPath,
        undefined,
        withInvocationId({
          headers: {
            'x-matched-path': '/isr/[slug].segments/_tree.segment.rsc',
            'x-now-route-matches': createNowRouteMatches({
              slug: 'first',
            }).toString(),
          },
        })
      )

      expect(res.status).toBe(200)
      expect(res.headers.get('content-type')).toBe('text/x-component')

      // We expect that because we're performing a segment prefetch, we
      // shouldn't even get to the cache entry handler.
      expect(res.headers.has('x-nextjs-cache-entry-handler')).toBe(false)
    }

    expect(
      cliOutput.substring(outputIdx).match(/rendering \/isr\/\[slug\]/g).length
    ).toBe(1)
  })

  it('should properly stream resume with Next-Resume', async () => {
    const res = await fetchViaHTTP(
      appPort,
      '/delayed',
      undefined,
      withInvocationId({
        headers: {
          'x-matched-path': '/delayed',
          'next-resume': '1',
        },
        method: 'POST',
        body: delayedPostpone,
      })
    )

    expect(res.status).toBe(200)

    // We expect that because we're performing a resume, we should be a miss.
    expect(res.headers.get('x-nextjs-cache-entry-handler')).toBe('MISS_2')

    let chunks = []

    for await (const chunk of res.body) {
      chunks.push({
        time: Date.now(),
        chunk: chunk.toString(),
      })
    }

    const firstSuspense = chunks.find((item) => item.chunk.includes('time'))
    const secondSuspense = chunks.find((item) => item.chunk.includes('random'))

    expect(secondSuspense.time - firstSuspense.time).toBeGreaterThanOrEqual(
      2 * 1000
    )
  })

  it('should properly handle prerender for bot request', async () => {
    const res = await fetchViaHTTP(
      appPort,
      '/isr/first',
      undefined,
      withInvocationId({
        headers: {
          'user-agent':
            'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
          'x-matched-path': '/isr/first',
        },
      })
    )

    expect(res.status).toBe(200)
    const html = await res.text()
    const $ = cheerio.load(html)

    expect($('#page').text()).toBe('/isr/[slug]')

    const rscRes = await fetchViaHTTP(
      appPort,
      '/isr/first.rsc',
      undefined,
      withInvocationId({
        headers: {
          'user-agent':
            'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
          'x-matched-path': '/isr/first',
        },
      })
    )

    expect(rscRes.status).toBe(200)
  })

  it('should properly handle fallback for bot request', async () => {
    const res = await fetchViaHTTP(
      appPort,
      '/isr/[slug]',
      undefined,
      withInvocationId({
        headers: {
          'user-agent':
            'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
          'x-now-route-matches': createNowRouteMatches({
            slug: 'new',
          }).toString(),
          'x-matched-path': '/isr/[slug]',
        },
      })
    )

    expect(res.status).toBe(200)
    const html = await res.text()
    const $ = cheerio.load(html)

    expect($('#page').text()).toBe('/isr/[slug]')

    const rscRes = await fetchViaHTTP(
      appPort,
      '/isr/[slug].rsc',
      undefined,
      withInvocationId({
        headers: {
          'user-agent':
            'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.179 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
          'x-now-route-matches': createNowRouteMatches({
            slug: 'new',
          }).toString(),
          'x-matched-path': '/isr/[slug]',
        },
      })
    )

    expect(rscRes.status).toBe(200)
  })

  it('should properly handle resume request that looks like a data request', async () => {
    const metadata = await next.readJSON('.next/server/app/[...catchAll].meta')
    const postponed = metadata.postponed

    const res = await fetchViaHTTP(
      appPort,
      // The pathname here represents a route that doesn't actually exist, but
      // we want to simulate a pages route Link that performs a prefetch to a
      // route backed by PPR.
      `/_next/data/${next.buildId}/index.json`,
      undefined,
      withInvocationId({
        method: 'POST',
        headers: {
          'x-matched-path': '/[...catchAll]',
          'x-now-route-matches': createNowRouteMatches({
            catchAll: `_next/data/${next.buildId}/index.json`,
          }).toString(),
          'next-resume': '1',
        },
        body: postponed,
      })
    )

    // Expect that the status code is 422, we asked for a /_next/data route and
    // also indicated that we wanted to resume a PPR render (which is
    // impossible).
    expect(res.status).toBe(422)

    // We expect that because we have a short-circuit for these unprocessable
    // requests, we should not have a cache entry handler header because it
    // should never get reached.
    expect(res.headers.has('x-nextjs-cache-entry-handler')).toBe(false)

    // Expect that the response body is empty.
    const html = await res.text()
    expect(html).toBeEmpty()
  })

  describe('middleware rewrite', () => {
    it('should work with a dynamic path with Next-Resume', async () => {
      const res = await fetchViaHTTP(
        appPort,
        '/rewrite-with-cookie',
        undefined,
        withInvocationId({
          method: 'POST',
          headers: {
            'x-matched-path': '/rewrite/first-cookie',
            'next-resume': '1',
          },
          body: rewritePostpone,
        })
      )

      expect(res.status).toBe(200)

      // We expect that because we're performing a resume, we should be a miss.
      expect(res.headers.get('x-nextjs-cache-entry-handler')).toBe('MISS_2')

      const html = await res.text()
      const $ = cheerio.load(html)

      expect($('#page').text()).toBe('/rewrite/[slug]')
      expect($('#params').text()).toBe(JSON.stringify({ slug: 'first-cookie' }))
    })
  })

  it('should still render when postponed is corrupted with Next-Resume', async () => {
    const random = Math.random().toString(36).substring(2)

    const res = await fetchViaHTTP(
      appPort,
      '/dyn/' + random,
      undefined,
      withInvocationId({
        method: 'POST',
        headers: {
          'x-matched-path': '/dyn/[slug]',
          'next-resume': '1',
        },
        // This is a corrupted postponed JSON payload.
        body: '{',
      })
    )

    expect(res.status).toBe(200)

    // We expect that because we're performing a resume, we should be a miss.
    expect(res.headers.get('x-nextjs-cache-entry-handler')).toBe('MISS_2')

    const html = await res.text()

    // Expect that the closing HTML tag is still present, indicating a
    // successful render.
    expect(html).toContain('</html>')
  })

  it('should send cache tags in minimal mode for ISR', async () => {
    for (const [path, tags] of [
      [
        '/isr/first',
        '_N_T_/layout,_N_T_/isr/layout,_N_T_/isr/[slug]/layout,_N_T_/isr/[slug]/page,_N_T_/isr/first,isr-page',
      ],
      [
        '/isr/second',
        '_N_T_/layout,_N_T_/isr/layout,_N_T_/isr/[slug]/layout,_N_T_/isr/[slug]/page,_N_T_/isr/second,isr-page',
      ],
      [
        '/api/isr/first',
        '_N_T_/layout,_N_T_/api/layout,_N_T_/api/isr/layout,_N_T_/api/isr/[slug]/layout,_N_T_/api/isr/[slug]/route,_N_T_/api/isr/first,isr-page',
      ],
      [
        '/api/isr/second',
        '_N_T_/layout,_N_T_/api/layout,_N_T_/api/isr/layout,_N_T_/api/isr/[slug]/layout,_N_T_/api/isr/[slug]/route,_N_T_/api/isr/second,isr-page',
      ],
    ]) {
      require('console').error('checking', { path, tags })
      const res = await fetchViaHTTP(
        appPort,
        path,
        undefined,
        withInvocationId({
          redirect: 'manual',
        })
      )
      expect(res.status).toBe(200)
      expect(res.headers.get('x-next-cache-tags')).toBe(tags)

      if (!path.startsWith('/api')) {
        // We expect that because these aren't ISR'ed, they should all be a
        // hit!
        expect(res.headers.get('x-nextjs-cache-entry-handler')).toBe('HIT_2')
      }
    }
  })

  it('should not send cache tags in minimal mode for SSR', async () => {
    for (const path of [
      '/ssr/first',
      '/ssr/second',
      '/api/ssr/first',
      '/api/ssr/second',
    ]) {
      const res = await fetchViaHTTP(
        appPort,
        path,
        undefined,
        withInvocationId({
          redirect: 'manual',
        })
      )

      expect(res.status).toBe(200)
      expect(res.headers.get('x-next-cache-tags')).toBeFalsy()

      if (!path.startsWith('/api')) {
        // We expect that because these aren't SSR'ed, they should all be a
        // miss.
        expect(res.headers.get('x-nextjs-cache-entry-handler')).toBe('MISS_1')
      }
    }
  })

  it('should not send invalid soft tags to cache handler', async () => {
    for (const path of [
      '/ssr/first',
      '/ssr/second',
      '/api/ssr/first',
      '/api/ssr/second',
    ]) {
      const res = await fetchViaHTTP(
        appPort,
        path,
        { hello: 'world' },
        withInvocationId({
          redirect: 'manual',
        })
      )

      expect(res.status).toBe(200)
      expect(res.headers.get('x-next-cache-tags')).toBeFalsy()

      if (!path.startsWith('/api')) {
        // We expect that because these aren't SSR'ed, they should all be a
        // miss.
        expect(res.headers.get('x-nextjs-cache-entry-handler')).toBe('MISS_1')
      }
    }
  })

  it('should handle RSC requests', async () => {
    const res = await fetchViaHTTP(
      appPort,
      '/dyn/first.rsc',
      undefined,
      withInvocationId({
        headers: {
          'x-matched-path': '/dyn/[slug]',
          'x-now-route-matches': createNowRouteMatches({
            slug: 'first',
          }).toString(),
        },
      })
    )

    expect(res.status).toBe(200)
    expect(res.headers.get('content-type')).toEqual('text/x-component')
    expect(res.headers.has('x-nextjs-postponed')).toBeFalse()
  })

  it('should use the postponed state for the RSC requests', async () => {
    // Let's parse the random number out of the HTML that was generated at build
    // time. We want to use that value as it's the one that's tied to the
    // postponed state that we also have.
    const $ = cheerio.load(secondCookieHTML)

    const random = $('#random').text()
    expect(random).toBeDefined()
    expect(random.length).toBeGreaterThan(0)

    // Record the start of the logs for this test.
    let start = cliOutput.length

    // Then let's do a Dynamic RSC request and verify that the random value is
    // not present in the response without passing the postponed state.
    let res = await fetchViaHTTP(
      appPort,
      '/rewrite/second-cookie.rsc',
      undefined,
      withInvocationId({
        headers: {
          'x-matched-path': '/rewrite/[slug]',
          'x-now-route-matches': createNowRouteMatches({
            slug: 'second-cookie',
          }).toString(),
        },
      })
    )

    expect(res.status).toBe(200)
    expect(res.headers.get('content-type')).toEqual('text/x-component')
    expect(res.headers.has('x-nextjs-postponed')).toBeFalse()

    // We expect that because we requested this dynamic RSC route without
    // resuming it, it should be a hit because it should be producing a new
    // static render.
    expect(res.headers.get('x-nextjs-cache-entry-handler')).toBe('HIT_2')

    // We expect that the random value is not present in the response because
    // we're not providing a resume data cache via the postponed state.
    // Instead it'll contain another random number that's been generated at
    // runtime during this new static render.
    let rsc = await res.text()
    expect(rsc).not.toContain(random)

    // Ensure that we hit the cache handler and not the resume data cache.
    await retry(() => {
      expect(cliOutput.substring(start)).toContain('cache-handler get')
      expect(cliOutput.substring(start)).toContain('cache-handler set')

      // We expect that there is both a miss and a hit because we're producing a
      // new static render.
      expect(cliOutput.substring(start)).toContain('rdc:miss')
      expect(cliOutput.substring(start)).toContain('rdc:set')
    })

    // Reset the start of the logs for this test.
    start = cliOutput.length

    // Then let's get the Dynamic RSC request and verify that the random value
    // is present in the response by passing the postponed state.
    res = await fetchViaHTTP(
      appPort,
      '/rewrite/second-cookie.rsc',
      undefined,
      withInvocationId({
        method: 'POST',
        headers: {
          'x-matched-path': '/rewrite/[slug]',
          'x-now-route-matches': createNowRouteMatches({
            slug: 'second-cookie',
          }).toString(),
          'next-resume': '1',
        },
        body: secondCookiePostpone,
      })
    )

    expect(res.status).toBe(200)
    expect(res.headers.get('content-type')).toEqual('text/x-component')
    expect(res.headers.has('x-nextjs-postponed')).toBeFalse()

    // We expect that because we're resuming, it should be a miss, because it
    // won't be producing a static response, and instead will be performing a
    // dynamic render.
    expect(res.headers.get('x-nextjs-cache-entry-handler')).toBe('MISS_2')

    // We expect that the random value is present in the response because
    // we're providing a resume data cache via the postponed state.
    rsc = await res.text()
    expect(rsc).toContain(random)

    // Ensure that we hit the resume data cache and not the cache handler.
    await retry(() => {
      expect(cliOutput.substring(start)).not.toContain('cache-handler get')
      expect(cliOutput.substring(start)).not.toContain('cache-handler set')

      // We expect that there is a resume data cache hit because we're providing
      // a resume data cache via the postponed state.
      expect(cliOutput.substring(start)).toContain('rdc:hit')

      // We expect that there is no resume data cache miss because we're
      // providing a resume data cache via the postponed state.
      expect(cliOutput.substring(start)).not.toContain('rdc:miss')
      expect(cliOutput.substring(start)).not.toContain('rdc:no-resume-data')
    })
  })

  it('should handle revalidating the fallback page', async () => {
    const res = await fetchViaHTTP(
      appPort,
      '/postpone/isr/[slug]',
      undefined,
      withInvocationId({
        headers: {
          'x-matched-path': '/postpone/isr/[slug]',
          // We don't include the `x-now-route-matches` header because we want to
          // test that the fallback route params are correctly set.
          'x-now-route-matches': '',
        },
      })
    )

    expect(res.status).toBe(200)

    // We expect that the cache entry handler was hit because we're performing a
    // static render in minimal mode on a page that will suspend.
    expect(res.headers.get('x-nextjs-cache-entry-handler')).toBe('HIT_2')

    const html = await res.text()

    // We only expect a partial shell because the page will suspend.
    expect(html).not.toContain('</html>')

    const $ = cheerio.load(html)

    expect($('#page').text()).toBeEmpty()
    expect($('#params').text()).toBeEmpty()
    expect($('#now').text()).toBeEmpty()
    expect($('#slot-loading').text()).toBe('/[...catchAll]')
    expect($('#loading').text()).toBe('/postpone/isr/[slug]')
  })
})
Quest for Codev2.0.0
/
SIGN IN