Skip to content

Commit 8c02fb9

Browse files
razor-xclaude
andauthored
fix: Keep the rewrites when next.config.js is an ES module (#81)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9428487 commit 8c02fb9

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)