Skip to content

Commit 4e86b92

Browse files
committed
fix: Keep the rewrites when next.config.js is an ES module
copyNextjsConfig loads next.config.js with a require and only falls back to import when that throws. The require returns a module namespace rather than the config for an ES module in two cases: since Node.js 22.12 require of an ES module succeeds and returns a namespace, and the nsm bin registers esbuild-runner, which transpiles an ES module to CommonJS and marks the result with __esModule. Reading the config off the namespace left rewrites undefined, so it was dropped from the generated .nsm/next.config.ts. A project that relies on a rewrite to reach its routes then 404s on every request, with no error to explain it. Unwrap the default export whenever a namespace is loaded, whichever way it was loaded. Resolve rewrites into a copy of the config, since a module namespace is frozen, and pass a file URL to the import fallback so it also works on Windows. Extract the loading into loadNextjsConfig and cover the CommonJS object, CommonJS function and ES module configs with tests. The sample project used by the existing tests has a CommonJS config, so the ES module path was untested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V6bQ6zQqwT1vq9NkrG1UTm
1 parent 9428487 commit 4e86b92

6 files changed

Lines changed: 127 additions & 20 deletions

File tree

nsm/scripts/copy-nextjs-config.js

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,56 @@ const path = require("path")
22
const prettier = require("prettier")
33
const fs = require("fs/promises")
44
const { existsSync } = require("fs")
5+
const { pathToFileURL } = require("url")
56

6-
async function copyNextjsConfig() {
7-
const nextConfigPath = path.resolve(__dirname, "../../next.config.js")
7+
// A module namespace is not the config, the config is its default export.
8+
// The require below returns a namespace for an ES module in two cases: since
9+
// Node.js 22.12 require of an ES module succeeds and returns one, and the nsm
10+
// bin registers esbuild-runner, which transpiles an ES module to CommonJS and
11+
// marks the result with __esModule. Reading the config off the namespace left
12+
// rewrites undefined, which silently dropped it from the generated config.
13+
const interopDefault = (value) => {
14+
if (value == null) return value
15+
16+
const is_namespace =
17+
value[Symbol.toStringTag] === "Module" || value.__esModule === true
18+
19+
return is_namespace && "default" in value ? value.default : value
20+
}
821

9-
let nextConfig = {}
10-
if (existsSync(nextConfigPath)) {
22+
async function loadNextjsConfig(nextConfigPath) {
23+
let nextConfig
24+
try {
25+
nextConfig = interopDefault(require(nextConfigPath))
26+
} catch (errorA) {
1127
try {
12-
nextConfig = require(nextConfigPath)
13-
} catch (errorA) {
14-
try {
15-
nextConfig = (await import(nextConfigPath)).default
16-
} catch (errorB) {
17-
console.error(errorA)
18-
console.error(errorB)
19-
throw new Error(`Failed to load ${nextConfigPath}`)
20-
}
28+
nextConfig = interopDefault(await import(pathToFileURL(nextConfigPath)))
29+
} catch (errorB) {
30+
console.error(errorA)
31+
console.error(errorB)
32+
throw new Error(`Failed to load ${nextConfigPath}`)
2133
}
34+
}
2235

23-
if (typeof nextConfig === "function") {
24-
nextConfig = await nextConfig()
25-
}
36+
if (typeof nextConfig === "function") {
37+
nextConfig = await nextConfig()
38+
}
2639

27-
if (typeof nextConfig.rewrites === "function") {
28-
nextConfig.rewrites = await nextConfig.rewrites()
29-
}
40+
// A module namespace is frozen, so resolve rewrites into a copy.
41+
if (typeof nextConfig.rewrites === "function") {
42+
nextConfig = { ...nextConfig, rewrites: await nextConfig.rewrites() }
3043
}
3144

45+
return nextConfig
46+
}
47+
48+
async function copyNextjsConfig() {
49+
const nextConfigPath = path.resolve(__dirname, "../../next.config.js")
50+
51+
const nextConfig = existsSync(nextConfigPath)
52+
? await loadNextjsConfig(nextConfigPath)
53+
: {}
54+
3255
const nextConfigFile = await prettier.format(
3356
`export default ${JSON.stringify(nextConfig)}`,
3457
{ semi: false, parser: "babel" },
@@ -40,7 +63,7 @@ async function copyNextjsConfig() {
4063
)
4164
}
4265

43-
module.exports = { copyNextjsConfig }
66+
module.exports = { copyNextjsConfig, loadNextjsConfig }
4467

4568
if (require.main === module) {
4669
copyNextjsConfig()
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module.exports = async () => ({
2+
reactStrictMode: true,
3+
async rewrites() {
4+
return {
5+
beforeFiles: [
6+
{
7+
source: "/:path*",
8+
destination: "/api/:path*",
9+
},
10+
],
11+
}
12+
},
13+
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module.exports = {
2+
reactStrictMode: true,
3+
async rewrites() {
4+
return {
5+
beforeFiles: [
6+
{
7+
source: "/:path*",
8+
destination: "/api/:path*",
9+
},
10+
],
11+
}
12+
},
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export default {
2+
reactStrictMode: true,
3+
async rewrites() {
4+
return {
5+
beforeFiles: [
6+
{
7+
source: "/:path*",
8+
destination: "/api/:path*",
9+
},
10+
],
11+
}
12+
},
13+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "next-config-module-fixture",
3+
"private": true,
4+
"type": "module"
5+
}

tests/load-nextjs-config.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import test from "ava"
2+
import { loadNextjsConfig } from "nsm/scripts/copy-nextjs-config"
3+
import path from "path"
4+
5+
const configPath = (name: string) =>
6+
path.resolve(__dirname, "assets", "next-configs", name, "next.config.js")
7+
8+
const expected_rewrites = {
9+
beforeFiles: [
10+
{
11+
source: "/:path*",
12+
destination: "/api/:path*",
13+
},
14+
],
15+
}
16+
17+
test("loads a CommonJS config", async (t) => {
18+
const config = await loadNextjsConfig(configPath("commonjs"))
19+
20+
t.true(config.reactStrictMode)
21+
t.deepEqual(config.rewrites, expected_rewrites)
22+
})
23+
24+
test("loads a CommonJS config exporting a function", async (t) => {
25+
const config = await loadNextjsConfig(configPath("commonjs-function"))
26+
27+
t.true(config.reactStrictMode)
28+
t.deepEqual(config.rewrites, expected_rewrites)
29+
})
30+
31+
// The nsm bin registers esbuild-runner, which transpiles an ES module to
32+
// CommonJS, and require of an ES module returns a module namespace since
33+
// Node.js 22.12. Both give the require in loadNextjsConfig a namespace instead
34+
// of the config, and reading rewrites off the namespace silently dropped it.
35+
test("loads an ES module config", async (t) => {
36+
const config = await loadNextjsConfig(configPath("module"))
37+
38+
t.true(config.reactStrictMode)
39+
t.deepEqual(config.rewrites, expected_rewrites)
40+
})

0 commit comments

Comments
 (0)