From 4e86b92a89a8a872d653d4f4b0b19172b6946de5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 14:19:36 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01V6bQ6zQqwT1vq9NkrG1UTm --- nsm/scripts/copy-nextjs-config.js | 63 +++++++++++++------ .../commonjs-function/next.config.js | 13 ++++ .../next-configs/commonjs/next.config.js | 13 ++++ .../assets/next-configs/module/next.config.js | 13 ++++ tests/assets/next-configs/module/package.json | 5 ++ tests/load-nextjs-config.test.ts | 40 ++++++++++++ 6 files changed, 127 insertions(+), 20 deletions(-) create mode 100644 tests/assets/next-configs/commonjs-function/next.config.js create mode 100644 tests/assets/next-configs/commonjs/next.config.js create mode 100644 tests/assets/next-configs/module/next.config.js create mode 100644 tests/assets/next-configs/module/package.json create mode 100644 tests/load-nextjs-config.test.ts diff --git a/nsm/scripts/copy-nextjs-config.js b/nsm/scripts/copy-nextjs-config.js index 9261790..59477bf 100644 --- a/nsm/scripts/copy-nextjs-config.js +++ b/nsm/scripts/copy-nextjs-config.js @@ -2,33 +2,56 @@ const path = require("path") const prettier = require("prettier") const fs = require("fs/promises") const { existsSync } = require("fs") +const { pathToFileURL } = require("url") -async function copyNextjsConfig() { - const nextConfigPath = path.resolve(__dirname, "../../next.config.js") +// A module namespace is not the config, the config is its default export. +// The require below returns a namespace for an ES module in two cases: since +// Node.js 22.12 require of an ES module succeeds and returns one, 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, which silently dropped it from the generated config. +const interopDefault = (value) => { + if (value == null) return value + + const is_namespace = + value[Symbol.toStringTag] === "Module" || value.__esModule === true + + return is_namespace && "default" in value ? value.default : value +} - let nextConfig = {} - if (existsSync(nextConfigPath)) { +async function loadNextjsConfig(nextConfigPath) { + let nextConfig + try { + nextConfig = interopDefault(require(nextConfigPath)) + } catch (errorA) { try { - nextConfig = require(nextConfigPath) - } catch (errorA) { - try { - nextConfig = (await import(nextConfigPath)).default - } catch (errorB) { - console.error(errorA) - console.error(errorB) - throw new Error(`Failed to load ${nextConfigPath}`) - } + nextConfig = interopDefault(await import(pathToFileURL(nextConfigPath))) + } catch (errorB) { + console.error(errorA) + console.error(errorB) + throw new Error(`Failed to load ${nextConfigPath}`) } + } - if (typeof nextConfig === "function") { - nextConfig = await nextConfig() - } + if (typeof nextConfig === "function") { + nextConfig = await nextConfig() + } - if (typeof nextConfig.rewrites === "function") { - nextConfig.rewrites = await nextConfig.rewrites() - } + // A module namespace is frozen, so resolve rewrites into a copy. + if (typeof nextConfig.rewrites === "function") { + nextConfig = { ...nextConfig, rewrites: await nextConfig.rewrites() } } + return nextConfig +} + +async function copyNextjsConfig() { + const nextConfigPath = path.resolve(__dirname, "../../next.config.js") + + const nextConfig = existsSync(nextConfigPath) + ? await loadNextjsConfig(nextConfigPath) + : {} + const nextConfigFile = await prettier.format( `export default ${JSON.stringify(nextConfig)}`, { semi: false, parser: "babel" }, @@ -40,7 +63,7 @@ async function copyNextjsConfig() { ) } -module.exports = { copyNextjsConfig } +module.exports = { copyNextjsConfig, loadNextjsConfig } if (require.main === module) { copyNextjsConfig() diff --git a/tests/assets/next-configs/commonjs-function/next.config.js b/tests/assets/next-configs/commonjs-function/next.config.js new file mode 100644 index 0000000..e68803f --- /dev/null +++ b/tests/assets/next-configs/commonjs-function/next.config.js @@ -0,0 +1,13 @@ +module.exports = async () => ({ + reactStrictMode: true, + async rewrites() { + return { + beforeFiles: [ + { + source: "/:path*", + destination: "/api/:path*", + }, + ], + } + }, +}) diff --git a/tests/assets/next-configs/commonjs/next.config.js b/tests/assets/next-configs/commonjs/next.config.js new file mode 100644 index 0000000..39bbda6 --- /dev/null +++ b/tests/assets/next-configs/commonjs/next.config.js @@ -0,0 +1,13 @@ +module.exports = { + reactStrictMode: true, + async rewrites() { + return { + beforeFiles: [ + { + source: "/:path*", + destination: "/api/:path*", + }, + ], + } + }, +} diff --git a/tests/assets/next-configs/module/next.config.js b/tests/assets/next-configs/module/next.config.js new file mode 100644 index 0000000..ddc0785 --- /dev/null +++ b/tests/assets/next-configs/module/next.config.js @@ -0,0 +1,13 @@ +export default { + reactStrictMode: true, + async rewrites() { + return { + beforeFiles: [ + { + source: "/:path*", + destination: "/api/:path*", + }, + ], + } + }, +} diff --git a/tests/assets/next-configs/module/package.json b/tests/assets/next-configs/module/package.json new file mode 100644 index 0000000..a7ad87e --- /dev/null +++ b/tests/assets/next-configs/module/package.json @@ -0,0 +1,5 @@ +{ + "name": "next-config-module-fixture", + "private": true, + "type": "module" +} diff --git a/tests/load-nextjs-config.test.ts b/tests/load-nextjs-config.test.ts new file mode 100644 index 0000000..6774f53 --- /dev/null +++ b/tests/load-nextjs-config.test.ts @@ -0,0 +1,40 @@ +import test from "ava" +import { loadNextjsConfig } from "nsm/scripts/copy-nextjs-config" +import path from "path" + +const configPath = (name: string) => + path.resolve(__dirname, "assets", "next-configs", name, "next.config.js") + +const expected_rewrites = { + beforeFiles: [ + { + source: "/:path*", + destination: "/api/:path*", + }, + ], +} + +test("loads a CommonJS config", async (t) => { + const config = await loadNextjsConfig(configPath("commonjs")) + + t.true(config.reactStrictMode) + t.deepEqual(config.rewrites, expected_rewrites) +}) + +test("loads a CommonJS config exporting a function", async (t) => { + const config = await loadNextjsConfig(configPath("commonjs-function")) + + t.true(config.reactStrictMode) + t.deepEqual(config.rewrites, expected_rewrites) +}) + +// The nsm bin registers esbuild-runner, which transpiles an ES module to +// CommonJS, and require of an ES module returns a module namespace since +// Node.js 22.12. Both give the require in loadNextjsConfig a namespace instead +// of the config, and reading rewrites off the namespace silently dropped it. +test("loads an ES module config", async (t) => { + const config = await loadNextjsConfig(configPath("module")) + + t.true(config.reactStrictMode) + t.deepEqual(config.rewrites, expected_rewrites) +})