Repository: vercel/next.js
Pull request: Turbopack: fix ESM project in standalone mode

Closes PACK-4513

We are missing the `.next/package.json` file containing `{"type": "commonjs"}` in the NFT manifest when using Turbopack

This means that if you have a project with `type: "module"`, then the internal `require(".../page.js")` would fail

Webpack lists them in the page-specific NFT files. For Turbopack, it's much easier though to add this in the `next-server.js.nft.json` and `next-minimal-server.js.nft.json` files (and this `.next/package.json` file isn't page-specific anyway)?

Changed files:
filename: packages/next/src/build/collect-build-traces.ts
patch: @@ -82,6 +82,7 @@ export async function collectBuildTraces({
   hasSsrAmpPages,
   buildTraceContext,
   outputFileTracingRoot,
+  isTurbopack,
 }: {
   dir: string
   distDir: string
@@ -93,6 +94,7 @@ export async function collectBuildTraces({
   nextBuildSpan?: Span
   config: NextConfigComplete
   buildTraceContext?: BuildTraceContext
+  isTurbopack: boolean
 }) {
   const startTime = Date.now()
   debug('starting build traces')
@@ -278,6 +280,11 @@ export async function collectBuildTraces({
         )
       }
 
+      if (isTurbopack) {
+        addToTracedFiles(distDir, './package.json', serverTracedFiles)
+        addToTracedFiles(distDir, './package.json', minimalServerTracedFiles)
+      }
+
       {
         const chunksToTrace: string[] = [
           ...(buildTraceContext?.chunksTrace?.action.input || []),

filename: packages/next/src/build/index.ts
patch: @@ -1516,6 +1516,7 @@ export default async function build(
                     hasSsrAmpPages: false,
                     buildTraceContext,
                     outputFileTracingRoot,
+                    isTurbopack: false,
                   })
                   .catch((err) => {
                     console.error(err)
@@ -2441,6 +2442,7 @@ export default async function build(
           hasSsrAmpPages,
           buildTraceContext,
           outputFileTracingRoot,
+          isTurbopack: true,
         }).catch((err) => {
           console.error(err)
           process.exit(1)

filename: test/e2e/app-dir/app-esm-js/standalone.test.ts
patch: @@ -0,0 +1,72 @@
+import { FileRef, nextTestSetup } from 'e2e-utils'
+import fs from 'fs-extra'
+import os from 'os'
+import path from 'path'
+import {
+  findPort,
+  initNextServerScript,
+  killApp,
+  fetchViaHTTP,
+} from 'next-test-utils'
+
+if (!(globalThis as any).isNextStart) {
+  it('should skip for non-next start', () => {})
+} else {
+  describe('output: standalone with ESM app dir', () => {
+    const { next, skipped } = nextTestSetup({
+      files: {
+        app: new FileRef(path.join(__dirname, 'app')),
+        pages: new FileRef(path.join(__dirname, 'pages')),
+        public: new FileRef(path.join(__dirname, 'public')),
+        'next.config.js': 'export default {"output": "standalone"}',
+      },
+      packageJson: {
+        type: 'module',
+      },
+      skipStart: true,
+    })
+
+    if (skipped) {
+      return
+    }
+
+    beforeAll(async () => {
+      await next.start()
+    })
+
+    it('should work correctly with output standalone', async () => {
+      const tmpFolder = path.join(os.tmpdir(), 'next-standalone-' + Date.now())
+      await fs.mkdirp(tmpFolder)
+      await fs.writeFile(
+        path.join(tmpFolder, 'package.json'),
+        '{"type": "module"}'
+      )
+      const distFolder = path.join(tmpFolder, 'test')
+      await fs.move(path.join(next.testDir, '.next/standalone'), distFolder)
+      let server: any
+      try {
+        const testServer = path.join(distFolder, 'server.js')
+        const appPort = await findPort()
+        server = await initNextServerScript(
+          testServer,
+          /- Local:/,
+          {
+            ...process.env,
+            PORT: appPort.toString(),
+          },
+          undefined,
+          {
+            cwd: distFolder,
+          }
+        )
+        for (const testPath of ['/app', '/pages', '/opengraph-image']) {
+          const res = await fetchViaHTTP(appPort, testPath)
+          expect(res.status).toBe(200)
+        }
+      } finally {
+        if (server) await killApp(server)
+        await fs.remove(tmpFolder)
+      }
+    })
+  })
+}