Skip to content

Commit

Permalink
Allow sync loading of ESM when --experimental-require-module (babel…
Browse files Browse the repository at this point in the history
  • Loading branch information
nicolo-ribaudo authored Sep 26, 2024
1 parent 367ab6c commit b2ba04c
Show file tree
Hide file tree
Showing 10 changed files with 156 additions and 36 deletions.
8 changes: 6 additions & 2 deletions packages/babel-core/src/config/files/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as fs from "../../gensync-utils/fs.ts";

import { createRequire } from "module";
import { endHiddenCallStack } from "../../errors/rewrite-stack-trace.ts";
import { isAsync } from "../../gensync-utils/async.ts";
const require = createRequire(import.meta.url);

const debug = buildDebug("babel:config:loading:files:configuration");
Expand Down Expand Up @@ -72,8 +73,12 @@ function* readConfigCode(

let options = yield* loadCodeDefault(
filepath,
(yield* isAsync()) ? "auto" : "require",
"You appear to be using a native ECMAScript module configuration " +
"file, which is only supported when running Babel asynchronously.",
"file, which is only supported when running Babel asynchronously " +
"or when using the Node.js `--experimental-require-module` flag.",
"You appear to be using a configuration file that contains top-level " +
"await, which is only supported when running Babel asynchronously.",
);

let cacheNeedsConfiguration = false;
Expand All @@ -92,7 +97,6 @@ function* readConfigCode(
if (typeof options.then === "function") {
// @ts-expect-error We use ?. in case options is a thenable but not a promise
options.catch?.(() => {});

throw new ConfigError(
`You appear to be using an async configuration, ` +
`which your current version of Babel does not support. ` +
Expand Down
8 changes: 6 additions & 2 deletions packages/babel-core/src/config/files/index-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,17 @@ export function* resolveShowConfigPath(

export const ROOT_CONFIG_FILENAMES: string[] = [];

type Resolved =
| { loader: "require"; filepath: string }
| { loader: "import"; filepath: string };

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function resolvePlugin(name: string, dirname: string): string | null {
export function resolvePlugin(name: string, dirname: string): Resolved | null {
return null;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function resolvePreset(name: string, dirname: string): string | null {
export function resolvePreset(name: string, dirname: string): Resolved | null {
return null;
}

Expand Down
67 changes: 50 additions & 17 deletions packages/babel-core/src/config/files/module-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,26 @@ function loadCjsDefault(filepath: string) {
}

if (process.env.BABEL_8_BREAKING) {
return module?.__esModule ? module.default : module;
return module != null &&
(module.__esModule || module[Symbol.toStringTag] === "Module")
? module.default
: module;
} else {
return module?.__esModule
return module != null &&
(module.__esModule || module[Symbol.toStringTag] === "Module")
? module.default ||
/* fallbackToTranspiledModule */ (arguments[1] ? module : undefined)
: module;
}
}

const loadMjsDefault = endHiddenCallStack(async function loadMjsDefault(
const loadMjsFromPath = endHiddenCallStack(async function loadMjsFromPath(
filepath: string,
) {
const url = pathToFileURL(filepath).toString();

if (process.env.BABEL_8_BREAKING) {
return (await import(url)).default;
return await import(url);
} else {
if (!import_) {
throw new ConfigError(
Expand All @@ -79,16 +83,29 @@ const loadMjsDefault = endHiddenCallStack(async function loadMjsDefault(
);
}

return (await import_(url)).default;
return await import_(url);
}
});

const SUPPORTED_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".cts"] as const);
type SetValue<T extends Set<unknown>> = T extends Set<infer U> ? U : never;

export default function* loadCodeDefault(
filepath: string,
asyncError: string,
loader: "require" | "auto",
esmError: string,
tlaError: string,
): Handler<unknown> {
switch (path.extname(filepath)) {
case ".cjs":
let async;

let ext = path.extname(filepath);
if (!SUPPORTED_EXTENSIONS.has(ext as any)) ext = ".js";

const pattern =
`${loader} ${ext}` as `${typeof loader} ${SetValue<typeof SUPPORTED_EXTENSIONS>}`;
switch (pattern) {
case "require .cjs":
case "auto .cjs":
if (process.env.BABEL_8_BREAKING) {
return loadCjsDefault(filepath);
} else {
Expand All @@ -98,11 +115,12 @@ export default function* loadCodeDefault(
/* fallbackToTranspiledModule */ arguments[2],
);
}
case ".mjs":
break;
case ".cts":
case "require .cts":
case "auto .cts":
return loadCtsDefault(filepath);
default:
case "auto .js":
case "require .js":
case "require .mjs": // Some versions of Node.js support require(esm):
try {
if (process.env.BABEL_8_BREAKING) {
return loadCjsDefault(filepath);
Expand All @@ -114,13 +132,28 @@ export default function* loadCodeDefault(
);
}
} catch (e) {
if (e.code !== "ERR_REQUIRE_ESM") throw e;
if (
e.code === "ERR_REQUIRE_ASYNC_MODULE" &&
!(async ??= yield* isAsync())
) {
throw new ConfigError(tlaError, filepath);
}
if (
e.code !== "ERR_REQUIRE_ESM" &&
(process.env.BABEL_8_BREAKING || ext !== ".mjs")
) {
throw e;
}
}
// fall through: require() failed due to ESM or TLA, try import()
case "auto .mjs":
if ((async ??= yield* isAsync())) {
return (yield* waitFor(loadMjsFromPath(filepath))).default;
}
throw new ConfigError(esmError, filepath);
default:
throw new Error("Internal Babel error: unreachable code.");
}
if (yield* isAsync()) {
return yield* waitFor(loadMjsDefault(filepath));
}
throw new ConfigError(asyncError, filepath);
}

function loadCtsDefault(filepath: string) {
Expand Down
32 changes: 22 additions & 10 deletions packages/babel-core/src/config/files/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ export function* loadPlugin(
name: string,
dirname: string,
): Handler<{ filepath: string; value: unknown }> {
const filepath = resolvePlugin(name, dirname, yield* isAsync());
const { filepath, loader } = resolvePlugin(name, dirname, yield* isAsync());

const value = yield* requireModule("plugin", filepath);
const value = yield* requireModule("plugin", loader, filepath);
debug("Loaded plugin %o from %o.", name, dirname);

return { filepath, value };
Expand All @@ -47,9 +47,9 @@ export function* loadPreset(
name: string,
dirname: string,
): Handler<{ filepath: string; value: unknown }> {
const filepath = resolvePreset(name, dirname, yield* isAsync());
const { filepath, loader } = resolvePreset(name, dirname, yield* isAsync());

const value = yield* requireModule("preset", filepath);
const value = yield* requireModule("preset", loader, filepath);

debug("Loaded preset %o from %o.", name, dirname);

Expand Down Expand Up @@ -167,7 +167,7 @@ function resolveStandardizedNameForRequire(
while (!res.done) {
res = it.next(tryRequireResolve(res.value, dirname));
}
return res.value;
return { loader: "require" as const, filepath: res.value };
}
function resolveStandardizedNameForImport(
type: "plugin" | "preset",
Expand All @@ -183,23 +183,23 @@ function resolveStandardizedNameForImport(
while (!res.done) {
res = it.next(tryImportMetaResolve(res.value, parentUrl));
}
return fileURLToPath(res.value);
return { loader: "auto" as const, filepath: fileURLToPath(res.value) };
}

function resolveStandardizedName(
type: "plugin" | "preset",
name: string,
dirname: string,
resolveESM: boolean,
allowAsync: boolean,
) {
if (!supportsESM || !resolveESM) {
if (!supportsESM || !allowAsync) {
return resolveStandardizedNameForRequire(type, name, dirname);
}

try {
const resolved = resolveStandardizedNameForImport(type, name, dirname);
// import-meta-resolve 4.0 does not throw if the module is not found.
if (!existsSync(resolved)) {
if (!existsSync(resolved.filepath)) {
throw Object.assign(
new Error(`Could not resolve "${name}" in file ${dirname}.`),
{ type: "MODULE_NOT_FOUND" },
Expand All @@ -221,7 +221,11 @@ if (!process.env.BABEL_8_BREAKING) {
// eslint-disable-next-line no-var
var LOADING_MODULES = new Set();
}
function* requireModule(type: string, name: string): Handler<unknown> {
function* requireModule(
type: string,
loader: "require" | "auto",
name: string,
): Handler<unknown> {
if (!process.env.BABEL_8_BREAKING) {
if (!(yield* isAsync()) && LOADING_MODULES.has(name)) {
throw new Error(
Expand All @@ -240,13 +244,21 @@ function* requireModule(type: string, name: string): Handler<unknown> {
if (process.env.BABEL_8_BREAKING) {
return yield* loadCodeDefault(
name,
loader,
`You appear to be using a native ECMAScript module ${type}, ` +
"which is only supported when running Babel asynchronously " +
"or when using the Node.js `--experimental-require-module` flag.",
`You appear to be using a ${type} that contains top-level await, ` +
"which is only supported when running Babel asynchronously.",
);
} else {
return yield* loadCodeDefault(
name,
loader,
`You appear to be using a native ECMAScript module ${type}, ` +
"which is only supported when running Babel asynchronously " +
"or when using the Node.js `--experimental-require-module` flag.",
`You appear to be using a ${type} that contains top-level await, ` +
"which is only supported when running Babel asynchronously.",
// For backward compatibility, we need to support malformed presets
// defined as separate named exports rather than a single default
Expand Down
4 changes: 2 additions & 2 deletions packages/babel-core/test/async.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ describe("asynchronicity", () => {

await expect(spawnTransformSync()).rejects.toThrow(
`[BABEL]: You appear to be using a native ECMAScript module plugin, which is` +
` only supported when running Babel asynchronously.`,
` only supported when running Babel asynchronously`,
);
});

Expand Down Expand Up @@ -285,7 +285,7 @@ describe("asynchronicity", () => {

await expect(spawnTransformSync()).rejects.toThrow(
`[BABEL]: You appear to be using a native ECMAScript module preset, which is` +
` only supported when running Babel asynchronously.`,
` only supported when running Babel asynchronously`,
);
});

Expand Down
41 changes: 38 additions & 3 deletions packages/babel-core/test/esm-cjs-integration.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { execFile } from "child_process";
import { createRequire } from "module";
import { describeESM } from "$repo-utils";
import { describeESM, describeGte } from "$repo-utils";

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

async function run(name) {
async function run(name, ...flags) {
return new Promise((res, rej) => {
execFile(
process.execPath,
[require.resolve(`./fixtures/esm-cjs-integration/${name}`)],
[...flags, require.resolve(`./fixtures/esm-cjs-integration/${name}`)],
{ env: process.env },
(error, stdout, stderr) => {
if (error) rej(error);
Expand Down Expand Up @@ -111,3 +111,38 @@ describeESM("usage from cjs", () => {
`);
});
});

describeESM("sync loading of ESM plugins", () => {
it("without --experimental-require-module flag", async () => {
await expect(run("transform-sync-esm-plugin.mjs")).rejects.toThrow(
"You appear to be using a native ECMAScript module plugin, which is " +
"only supported when running Babel asynchronously or when using the " +
"Node.js `--experimental-require-module` flag.",
);
});

describeGte("20.0.0")("with --experimental-require-module flag", () => {
it("sync", async () => {
const { stdout } = await run(
"transform-sync-esm-plugin.mjs",
"--experimental-require-module",
);
expect(stdout).toMatchInlineSnapshot(`
"\\"Replaced!\\";
"
`);
});

it("top-level await", async () => {
await expect(
run(
"transform-sync-esm-plugin-tla.mjs",
"--experimental-require-module",
),
).rejects.toThrow(
"You appear to be using a plugin that contains top-level await, " +
"which is only supported when running Babel asynchronously.",
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { types as t } from "../../../../lib/index.js";

export default function () {
return {
visitor: {
Identifier(path) {
if (path.node.name === "REPLACE_ME") {
path.replaceWith(t.stringLiteral("Replaced!"));
}
},
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
await 0;

export default function () {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as babel from "../../../lib/index.js";
import { fileURLToPath } from "url";

const out = babel.transformSync("REPLACE_ME;", {
configFile: false,
plugins: [fileURLToPath(new URL("./plugins/esm-tla.mjs", import.meta.url))],
});
console.log(out.code);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as babel from "../../../lib/index.js";
import { fileURLToPath } from "url";

const out = babel.transformSync("REPLACE_ME;", {
configFile: false,
plugins: [fileURLToPath(new URL("./plugins/esm-sync.mjs", import.meta.url))],
});
console.log(out.code);

0 comments on commit b2ba04c

Please sign in to comment.