Files
cloudflare__vinext/tests/next-config-extensions.test.ts
T
James Anderson 8de3afdf25 fix(config): recognize next.config.mts and interop CJS imports in config loader (#1296)
* fix(config): recognize next.config.mts and interop CJS imports in config

The next.config.ts loader did not recognize next.config.mts and did not
interop CJS imports (.cjs/.cts/.js/.ts files that assign to module.exports)
brought in by the config file. As a result, fixtures from the Next.js
test/e2e/app-dir/next-config-ts-native-mts/ family failed with either
"Cannot find next.config.*" (silent null return) or "module is not
defined" inside Vite's ESM-only module runner.

Two changes:

1) The loader's local CONFIG_FILES list is extended to include
   next.config.mts (matching the canonical list in shims/constants.ts,
   which already mirrors Next.js packages/next/src/shared/lib/constants.ts)
   while keeping .cjs for backwards-compat with existing vinext users.
   deploy.ts and check.ts pick up the same extended list so their config
   scanners stay in sync.

2) A new Vite plugin (cjsImportsInteropPlugin) is installed on the inline
   runnerImport instance. It wraps .cjs/.cts files (and .js/.ts files that
   statically assign to module.exports) so that module.exports becomes the
   ESM default export, mirroring how Next.js's SWC pipeline auto-converts
   CJS imports from inside next.config.ts. The transform is scoped to the
   config-load instance only, so it never affects the user's application
   module graph.

Tests ported from .nextjs-ref/test/e2e/app-dir/next-config-ts-native-mts/
{nested-imports,import-js-extensions-cjs,import-js-extensions-esm,
import-from-node-modules}/.

* docs(config): clarify CJS exports/module.exports semantics in comment

The previous comment said "Named exports off module.exports are also
reflected", which glosses over the asymmetric behaviour that Node CJS
modules exhibit when user code does `module.exports = X` followed by
`exports.foo = Y`: the latter writes to the discarded original object
and does not show up on the final default export. Rewrite the comment to
spell out the aliasing, the reassignment break, and the
`module.exports.foo` workaround.

* refactor(config): reuse vite-plugin-commonjs for next.config sibling imports

The hand-rolled cjsImportsInteropPlugin duplicated logic that vinext
already pulls in via vite-plugin-commonjs (used globally for application
code in packages/vinext/src/index.ts:795). Drop the bespoke plugin and
install commonjs() on the inline runnerImport instance instead.

Two pieces of configuration are needed for parity with the previous
behaviour:

1. resolve.extensions explicitly includes .cjs and .cts. vite-plugin-commonjs
   keys its 'should I transform this id?' check off config.resolve.extensions,
   and Vite's default list (.mjs/.js/.mts/.ts/.jsx/.tsx/.json) omits both
   CJS-only extensions. Without this, .cjs/.cts imports flowed through
   the runner untransformed and still crashed with 'module is not defined'.

2. A filter that:
   - Returns false for the config file itself, so cjsGlobalsInjectorPlugin
     remains the sole producer of the file's module wrapper (otherwise
     both plugins inject 'module = …' and the runner trips on
     'Identifier module has already been declared').
   - Returns true for ids under node_modules, so bare-import packages
     imported by next.config.* (CJS plugin wrappers, etc.) get transformed.
     vite-plugin-commonjs defaults to skipping node_modules.

Net diff: -59 lines, all four next-config-extensions tests still pass,
no regression on tests/next-config.test.ts (108 passing) or the broader
deploy/features/shims/check suites (1637 passing in aggregate).
2026-05-18 12:07:44 +01:00

169 lines
6.7 KiB
TypeScript

import { describe, it, expect, afterEach } from "vite-plus/test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { loadNextConfig } from "../packages/vinext/src/config/next-config.js";
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "vinext-cfg-ext-"));
}
describe("loadNextConfig — Next.js next-config-ts-native extension/import parity", () => {
// These tests mirror the fixtures under .nextjs-ref/test/e2e/app-dir/
// next-config-ts-native-{mts,ts}/ that exercise extension probing,
// .mts/.cts imports, nested imports, etc.
let tmpDir: string;
afterEach(() => {
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
// Ported from Next.js: test/e2e/app-dir/next-config-ts-native-mts/nested-imports/
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/next-config-ts-native-mts/nested-imports/
it("resolves nested .ts imports from next.config.ts (explicit .ts extension)", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "tsconfig.json"),
JSON.stringify({ compilerOptions: { allowImportingTsExtensions: true } }),
);
fs.writeFileSync(path.join(tmpDir, "baz.ts"), `export const baz = 'baz';\n`);
fs.writeFileSync(
path.join(tmpDir, "bar.ts"),
`import { baz } from './baz.ts';\nexport const barbaz = 'bar' + baz;\n`,
);
fs.writeFileSync(
path.join(tmpDir, "foo.ts"),
`import { barbaz } from './bar.ts';\nexport const foobarbaz = 'foo' + barbaz;\n`,
);
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import { foobarbaz } from './foo.ts';\nexport default { env: { FOOBARBAZ: foobarbaz } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.FOOBARBAZ).toBe("foobarbaz");
});
// Ported from Next.js: test/e2e/app-dir/next-config-ts-native-mts/import-js-extensions-{cjs,esm}/
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/next-config-ts-native-mts/import-js-extensions-cjs/
it("resolves .mts/.cts/.mjs/.cjs/.ts/.js imports from next.config.mts", async () => {
tmpDir = makeTempDir();
// Mirrors fixtures dir
const fix = path.join(tmpDir, "fixtures");
fs.mkdirSync(fix);
fs.writeFileSync(path.join(fix, "cjs.cjs"), `module.exports = 'cjs'\n`);
fs.writeFileSync(path.join(fix, "mjs.mjs"), `export default 'mjs'\n`);
fs.writeFileSync(path.join(fix, "cts.cts"), `module.exports = 'cts'\n`);
fs.writeFileSync(path.join(fix, "mts.mts"), `export default 'mts'\n`);
fs.writeFileSync(path.join(fix, "ts.ts"), `export default 'ts'\n`);
fs.writeFileSync(path.join(fix, "js-cjs.js"), `module.exports = 'jsCJS'\n`);
fs.writeFileSync(
path.join(tmpDir, "next.config.mts"),
`import cjs from './fixtures/cjs.cjs'\n` +
`import mjs from './fixtures/mjs.mjs'\n` +
`import * as cts from './fixtures/cts.cts'\n` +
`import mts from './fixtures/mts.mts'\n` +
`import ts from './fixtures/ts.ts'\n` +
`import js from './fixtures/js-cjs.js'\n` +
`export default { env: { cjs, mjs, cts: (cts as any).default, mts, ts, js } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env).toEqual({
cjs: "cjs",
mjs: "mjs",
cts: "cts",
mts: "mts",
ts: "ts",
js: "jsCJS",
});
});
// Ported from Next.js: test/e2e/app-dir/next-config-ts-native-mts/import-from-node-modules/
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/next-config-ts-native-mts/import-from-node-modules/
it("resolves bare-import packages from next.config.ts (.cjs/.mjs/.js)", async () => {
tmpDir = makeTempDir();
// CJS package (no exports field, just main)
const cjsPkg = path.join(tmpDir, "node_modules", "cjs");
fs.mkdirSync(cjsPkg, { recursive: true });
fs.writeFileSync(
path.join(cjsPkg, "package.json"),
JSON.stringify({ name: "cjs", main: "index.cjs" }),
);
fs.writeFileSync(path.join(cjsPkg, "index.cjs"), `module.exports = 'cjs'\n`);
// MJS package
const mjsPkg = path.join(tmpDir, "node_modules", "mjs");
fs.mkdirSync(mjsPkg, { recursive: true });
fs.writeFileSync(
path.join(mjsPkg, "package.json"),
JSON.stringify({ name: "mjs", main: "index.mjs" }),
);
fs.writeFileSync(path.join(mjsPkg, "index.mjs"), `export default 'mjs'\n`);
// JS CJS package (no type field, default cjs)
const jsCjs = path.join(tmpDir, "node_modules", "js-cjs");
fs.mkdirSync(jsCjs, { recursive: true });
fs.writeFileSync(
path.join(jsCjs, "package.json"),
JSON.stringify({ name: "js-cjs", main: "index.js" }),
);
fs.writeFileSync(path.join(jsCjs, "index.js"), `module.exports = 'jsCJS'\n`);
// JS ESM package (type:module)
const jsEsm = path.join(tmpDir, "node_modules", "js-esm");
fs.mkdirSync(jsEsm, { recursive: true });
fs.writeFileSync(
path.join(jsEsm, "package.json"),
JSON.stringify({ name: "js-esm", main: "index.js", type: "module" }),
);
fs.writeFileSync(path.join(jsEsm, "index.js"), `export default 'jsESM'\n`);
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import cjs from 'cjs'\n` +
`import mjs from 'mjs'\n` +
`import jsCJS from 'js-cjs'\n` +
`import jsESM from 'js-esm'\n` +
`export default { env: { cjs, mjs, jsCJS, jsESM } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env).toEqual({
cjs: "cjs",
mjs: "mjs",
jsCJS: "jsCJS",
jsESM: "jsESM",
});
});
// Ported from Next.js: test/e2e/app-dir/next-config-ts-native-mts/nested-imports/
// (CJS variant — package.json with no "type" field)
it("resolves nested .ts imports when next.config.mts is in a CJS package", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({ name: "test", version: "1.0.0" }),
);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.json"),
JSON.stringify({ compilerOptions: { allowImportingTsExtensions: true } }),
);
fs.writeFileSync(path.join(tmpDir, "baz.ts"), `export const baz = 'baz';\n`);
fs.writeFileSync(
path.join(tmpDir, "bar.ts"),
`import { baz } from './baz.ts';\nexport const barbaz = 'bar' + baz;\n`,
);
fs.writeFileSync(
path.join(tmpDir, "foo.ts"),
`import { barbaz } from './bar.ts';\nexport const foobarbaz = 'foo' + barbaz;\n`,
);
fs.writeFileSync(
path.join(tmpDir, "next.config.mts"),
`import { foobarbaz } from './foo.ts';\nexport default { env: { FOOBARBAZ: foobarbaz } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.FOOBARBAZ).toBe("foobarbaz");
});
});