Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support .cts as configuration file #15283

Merged
merged 13 commits into from Feb 18, 2023
11 changes: 10 additions & 1 deletion packages/babel-core/package.json
Expand Up @@ -74,7 +74,16 @@
"@types/gensync": "^1.0.0",
"@types/resolve": "^1.3.2",
"@types/semver": "^5.4.0",
"rimraf": "^3.0.0"
"rimraf": "^3.0.0",
"ts-node": "^10.9.1"
},
"peerDependencies": {
"@babel/preset-typescript": "^7.0.0"
},
"peerDependenciesMeta": {
"@babel/preset-typescript": {
"optional": true
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is exactly how we should do it, but it's also a potential breaking change 🙁 peerDependenciesMeta is only supported in npm 7+, and Node.js 14 comes with npm 6 preinstalled. There are also other places where we would need peerDependenciesMeta, but they are waiting for Babel 8.

Maybe we could try/catch requiring the preset without listing it as a peer dependency, even if this means that for now it won't work with PnP? Or we could only support loading through ts-node until Babel 8.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a little hesitant about this, because even if the npm used by the user does not support peerDependenciesMeta, it should only install an additional plugin, and there will be no other side effects except for a very small hard disk occupation. 😕

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, in old npm versions that don't support peerDependenciesMeta @babel/preset-typescript will not be installed, and instead it will be handled like a required peer dependency.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get it, this causes a warning to show up in some versions of npm, which is really less than ideal, thanks!

},
"conditions": {
"BABEL_8_BREAKING": [
Expand Down
20 changes: 14 additions & 6 deletions packages/babel-core/src/config/files/configuration.ts
Expand Up @@ -9,7 +9,7 @@ import type { CacheConfigurator } from "../caching";
import { makeConfigAPI } from "../helpers/config-api";
import type { ConfigAPI } from "../helpers/config-api";
import { makeStaticFileCache } from "./utils";
import loadCjsOrMjsDefault from "./module-types";
import loadCodeDefault from "./module-types";
import pathPatternToRegex from "../pattern-to-regex";
import type { FilePackageData, RelativeConfig, ConfigFile } from "./types";
import type { CallerMetadata } from "../validation/options";
Expand All @@ -28,20 +28,22 @@ export const ROOT_CONFIG_FILENAMES = [
"babel.config.cjs",
"babel.config.mjs",
"babel.config.json",
"babel.config.cts",
];
const RELATIVE_CONFIG_FILENAMES = [
".babelrc",
".babelrc.js",
".babelrc.cjs",
".babelrc.mjs",
".babelrc.json",
".babelrc.cts",
];

const BABELIGNORE_FILENAME = ".babelignore";

const LOADING_CONFIGS = new Set();

const readConfigJS = makeStrongCache(function* readConfigJS(
const readConfigCode = makeStrongCache(function* readConfigCode(
filepath: string,
cache: CacheConfigurator<{
envName: string;
Expand Down Expand Up @@ -70,7 +72,7 @@ const readConfigJS = makeStrongCache(function* readConfigJS(
let options: unknown;
try {
LOADING_CONFIGS.add(filepath);
options = yield* loadCjsOrMjsDefault(
options = yield* loadCodeDefault(
filepath,
"You appear to be using a native ECMAScript module configuration " +
"file, which is only supported when running Babel asynchronously.",
Expand Down Expand Up @@ -313,9 +315,15 @@ function readConfig(
caller: CallerMetadata | undefined,
): Handler<ConfigFile | null> {
const ext = path.extname(filepath);
return ext === ".js" || ext === ".cjs" || ext === ".mjs"
? readConfigJS(filepath, { envName, caller })
: readConfigJSON5(filepath);
switch (ext) {
case ".js":
case ".cjs":
case ".mjs":
case ".cts":
return readConfigCode(filepath, { envName, caller });
default:
return readConfigJSON5(filepath);
}
}

export function* resolveShowConfigPath(
Expand Down
87 changes: 67 additions & 20 deletions packages/babel-core/src/config/files/module-types.ts
Expand Up @@ -3,11 +3,15 @@ import type { Handler } from "gensync";
import path from "path";
import { pathToFileURL } from "url";
import { createRequire } from "module";
import fs from "fs";
import semver from "semver";

import { endHiddenCallStack } from "../../errors/rewrite-stack-trace";
import ConfigError from "../../errors/config-error";

import { transformSync } from "../../transform";
import type { InputOptions } from "..";

const require = createRequire(import.meta.url);

let import_: ((specifier: string | URL) => any) | undefined;
Expand All @@ -23,38 +27,82 @@ export const supportsESM = semver.satisfies(
"^12.17 || >=13.2",
);

export default function* loadCjsOrMjsDefault(
export default function* loadCodeDefault(
filepath: string,
asyncError: string,
// TODO(Babel 8): Remove this
fallbackToTranspiledModule: boolean = false,
): Handler<unknown> {
switch (guessJSModuleType(filepath)) {
case "cjs":
switch (path.extname(filepath)) {
case ".cjs":
return loadCjsDefault(filepath, fallbackToTranspiledModule);
case "unknown":
case ".mjs":
break;
case ".cts":
return loadCtsDefault(filepath);
default:
try {
return loadCjsDefault(filepath, fallbackToTranspiledModule);
} catch (e) {
if (e.code !== "ERR_REQUIRE_ESM") throw e;
}
// fall through
case "mjs":
if (yield* isAsync()) {
return yield* waitFor(loadMjsDefault(filepath));
}
throw new ConfigError(asyncError, filepath);
}
if (yield* isAsync()) {
return yield* waitFor(loadMjsDefault(filepath));
}
throw new ConfigError(asyncError, filepath);
}

function guessJSModuleType(filename: string): "cjs" | "mjs" | "unknown" {
switch (path.extname(filename)) {
case ".cjs":
return "cjs";
case ".mjs":
return "mjs";
default:
return "unknown";
function loadCtsDefault(filepath: string) {
const ext = ".cts";
const hasTsSupport = !!(
require.extensions[".ts"] ||
require.extensions[".cts"] ||
require.extensions[".mts"]
Comment on lines +58 to +60
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the plan to eventually support .ts and .mts? Can we throw an error when we find babel.config.ts and babel.config.mts, instead of silently ignoring them?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I want to support this, but it may be difficult, and the current nodejs support for this is not very complete.

);
if (!hasTsSupport) {
const code = fs.readFileSync(filepath, "utf8");
const opts: InputOptions = {
babelrc: false,
configFile: false,
filename: path.basename(filepath),
sourceType: "script",
sourceMaps: "inline",
presets: [
[
"@babel/preset-typescript",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we first try require.resolveing this, and throw an error if there is a TS config file but not the preset, giving an error message that says why the users needs to install the preset instead of just saying "preset not found"?

process.env.BABEL_8_BREAKING
liuxingbaoyu marked this conversation as resolved.
Show resolved Hide resolved
? {
disallowAmbiguousJSXLike: true,
allExtensions: true,
onlyRemoveTypeImports: true,
optimizeConstEnums: true,
}
: {
allowDeclareFields: true,
disallowAmbiguousJSXLike: true,
allExtensions: true,
onlyRemoveTypeImports: true,
optimizeConstEnums: true,
},
],
],
};
const result = transformSync(code, opts);
liuxingbaoyu marked this conversation as resolved.
Show resolved Hide resolved
require.extensions[ext] = function (m, filename) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the require hook be installed before transformFileSync which invokes loadOptions? I am thinking of the case when babel.config.cts is importing a ts module.

if (filename === filepath) {
// @ts-expect-error Undocumented API
return m._compile(result.code, filename);
}
return require.extensions[".js"](m, filename);
};
}
try {
return endHiddenCallStack(require)(filepath);
} finally {
if (!hasTsSupport) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user config could register a new handler for TS extensions. We should also check if require.extensions[ext] is still === to the function we defined a few lines above, before deleting it.

delete require.extensions[ext];
}
}
}

Expand All @@ -69,8 +117,7 @@ function loadCjsDefault(filepath: string, fallbackToTranspiledModule: boolean) {
async function loadMjsDefault(filepath: string) {
if (!import_) {
throw new ConfigError(
"Internal error: Native ECMAScript modules aren't supported" +
" by this platform.\n",
"Internal error: Native ECMAScript modules aren't supported by this platform.\n",
filepath,
);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/babel-core/src/config/files/plugins.ts
Expand Up @@ -6,7 +6,7 @@ import buildDebug from "debug";
import path from "path";
import gensync, { type Handler } from "gensync";
import { isAsync } from "../../gensync-utils/async";
import loadCjsOrMjsDefault, { supportsESM } from "./module-types";
import loadCodeDefault, { supportsESM } from "./module-types";
import { fileURLToPath, pathToFileURL } from "url";

import importMetaResolve from "./import-meta-resolve";
Expand Down Expand Up @@ -217,7 +217,7 @@ function* requireModule(type: string, name: string): Handler<unknown> {
if (!process.env.BABEL_8_BREAKING) {
LOADING_MODULES.add(name);
}
return yield* loadCjsOrMjsDefault(
return yield* loadCodeDefault(
name,
`You appear to be using a native ECMAScript module ${type}, ` +
"which is only supported when running Babel asynchronously.",
Expand Down
82 changes: 82 additions & 0 deletions packages/babel-core/test/config-ts.js
@@ -0,0 +1,82 @@
import { loadPartialConfigSync } from "../lib/index.js";
import path from "path";
import { fileURLToPath } from "url";
import { createRequire } from "module";
import semver from "semver";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);

// We skip older versions of node testing for two reasons.
// 1. ts-node does not support the old version of node.
// 2. In the old version of node, jest has been registered in `require.extensions`, which will cause babel to disable the transforming as expected.

describe("@babel/core config with ts [dummy]", () => {
it("dummy", () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed because Jest complains if all the tests in the file are skipped?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Can you make it only run on node < 12?

expect(1).toBe(1);
});
});

semver.gte(process.version, "12.0.0")
? describe
: describe.skip("@babel/core config with ts", () => {
it("should work with simple .cts", () => {
const config = loadPartialConfigSync({
configFile: path.join(
__dirname,
"fixtures/config-ts/simple-cts/babel.config.cts",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, does new URL("./fixtures/config-ts/simple-cts/babel.config.cts", import.meta.url) work?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure, I've encountered a lot of URL with windows causing special exceptions, so I always use path. And they look just as readable.

),
});

expect(config.options.targets).toMatchInlineSnapshot(`
Object {
"node": "12.0.0",
}
`);
});

it("should throw with invalid .ts register", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a new test case for invalid .cts register? Since that is how we are detecting ts support.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is actually a special situation here. I found that ts-node will only register .ts, and other extensions are realized by hijacking .js.
I seem to need to add some comments.
Actually I think the best way to detect it here is probably to directly try to require() a file with only type xxx, but that would require us to include it in the package, I'm not sure it's worth it. (Also future .cts .ts .mts may require three different files, which is kind of ugly)

require.extensions[".ts"] = () => {
throw new Error("Not support .ts.");
};
expect(() => {
loadPartialConfigSync({
configFile: path.join(
__dirname,
"fixtures/config-ts/invalid-cts-register/babel.config.cts",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the fixture here be invalid-ts-register?

),
});
}).toThrow(/Unexpected identifier.*/);
delete require.extensions[".ts"];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use try/finally, so that the delete happens even if the expect fails.

});

it("should work with ts-node", async () => {
const service = eval("import('ts-node')").register({
liuxingbaoyu marked this conversation as resolved.
Show resolved Hide resolved
experimentalResolver: true,
compilerOptions: {
module: "CommonJS",
},
});
service.enabled(true);

require(path.join(
__dirname,
"fixtures/config-ts/simple-cts-with-ts-node/babel.config.cts",
));

const config = loadPartialConfigSync({
configFile: path.join(
__dirname,
"fixtures/config-ts/simple-cts-with-ts-node/babel.config.cts",
),
});

service.enabled(false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto. The test teardown should be placed in a try-finally block or wrapped in the afterAll setup.


expect(config.options.targets).toMatchInlineSnapshot(`
Object {
"node": "12.0.0",
}
`);
});
});
@@ -0,0 +1,2 @@
type config = any;
module.exports = { targets: "node 12.0.0" } as config;
@@ -0,0 +1,2 @@
type config = any;
module.exports = { targets: "node 12.0.0" } as config;
@@ -0,0 +1,2 @@
type config = any;
module.exports = { targets: "node 12.0.0" } as config;