Files
vercel__next.js/test/production/adapter-pages-api-runtime/adapter-pages-api-runtime.test.ts
Tim Neutkens d0798a0416 Fix missing Pages runtime in adapter Pages API outputs (#96819)
## Summary

### Original issue

Pages API functions produced through a build adapter can fail during
function initialization, before the customer handler executes:

```
Cannot find module 'next/dist/compiled/next-server/pages-turbo.runtime.prod.js'
```

The failure is triggered when an externalized dependency used by the API
route imports a Next.js module such as `next/head`. At runtime the load
path is:

```
external dependency
  -> next/head
  -> head-manager-context.shared-runtime
  -> Pages vendored head-manager context
  -> pages/module.compiled.js
  -> pages[-turbo].runtime.prod.js
```

The two important edges at the end of this chain are selected
dynamically. The Next.js require hook redirects the shared-runtime
import to the Pages vendored context, and `pages/module.compiled.js`
selects the bundler-specific production renderer. As a result, a Pages
API entry trace does not discover the regular Pages renderer. It
naturally contains `pages-api[-turbo].runtime.prod.js`, which is the
runtime for the API route module, but not
`pages[-turbo].runtime.prod.js`, which is reached through the external
`next/head` import.

This is why the failure is specific to Pages API routes. A regular Pages
SSR entry already references and traces the Pages renderer. App Router
entries use different route modules and traces.

### Fix

Trace the hidden Pages renderer dependency using the mechanism
appropriate to each bundler:

- **Turbopack:** add `pages-turbo.runtime.prod.js` as an explicit entry
in `Project::pages_traced_modules`. The existing native Turbopack module
graph then traces its full runtime closure. This does not run Node File
Trace and remains scoped to Pages endpoints.
- **Webpack:** run the existing Node File Trace path on
`pages.runtime.prod.js` and merge that closure into the Pages shared
assets. This code remains inside the non-Turbopack branch.

The existing require-hook modules remain separate, and App Router or
neutral endpoint trace sets are unchanged. No adapter change is
required.

### Regression test

The fixture externalizes a package that imports `next/head`, matching
the reported dependency boundary. The test builds with an adapter,
materializes only the generated Pages API function entry and its
provided assets into a clean directory, initializes the production
environment, and requires the handler in a child process.

This verifies the observable initialization behavior under both
Turbopack and Webpack rather than asserting a particular output file
list.

## Verification

- `cargo check -p next-api`
- `pnpm --filter=next build`
- `pnpm test-start-turbo
test/production/adapter-pages-api-runtime/adapter-pages-api-runtime.test.ts`
- `pnpm test-start-webpack
test/production/adapter-pages-api-runtime/adapter-pages-api-runtime.test.ts`

<!-- NEXT_JS_LLM -->

Co-authored-by: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com>
2026-08-14 10:38:28 +02:00

75 lines
2.4 KiB
TypeScript

import fs from 'fs/promises'
import os from 'os'
import path from 'path'
import execa from 'execa'
import { nextTestSetup } from 'e2e-utils'
import type { NextAdapter } from 'next'
describe('adapter-pages-api-runtime', () => {
const { next } = nextTestSetup({
files: __dirname,
skipStart: true,
})
it('loads an external next/head dependency from a Pages API output', async () => {
await next.build()
const { outputs, repoRoot }: Parameters<NextAdapter['onBuildComplete']>[0] =
await next.readJSON('build-complete.json')
const apiOutput = outputs.pagesApi.find(
(output) => output.pathname === '/api/hello'
)
expect(apiOutput).toBeDefined()
const functionDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'next-adapter-pages-api-')
)
try {
for (const [target, source] of Object.entries(apiOutput!.assets)) {
const destination = path.join(functionDir, target)
await fs.mkdir(path.dirname(destination), { recursive: true })
await fs.cp(source, destination, {
recursive: true,
verbatimSymlinks: true,
})
}
const entryPath = path.relative(repoRoot, apiOutput!.filePath)
const entryDestination = path.join(functionDir, entryPath)
await fs.mkdir(path.dirname(entryDestination), { recursive: true })
await fs.copyFile(apiOutput!.filePath, entryDestination)
const env: NodeJS.ProcessEnv = {
...process.env,
NODE_ENV: 'production',
}
if (process.env.IS_TURBOPACK_TEST) {
env.TURBOPACK = '1'
} else {
delete env.TURBOPACK
}
await execa(
process.execPath,
[
// Newer Node.js versions select @swc/helpers' `module-sync` export,
// which is unrelated to the Pages runtime behavior under test and is
// not represented in Turbopack's current output trace.
...(process.allowedNodeEnvironmentFlags.has(
'--no-experimental-require-module'
)
? ['--no-experimental-require-module']
: []),
'-e',
`require('next/setup-node-env'); const mod = require(${JSON.stringify(entryDestination)}); if (typeof mod.handler !== 'function') process.exit(1)`,
],
{ cwd: functionDir, env }
)
} finally {
await fs.rm(functionDir, { recursive: true, force: true })
}
})
})