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) +})