Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
499 changes: 481 additions & 18 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"cspell": "^9.4.0",
"css-loader": "^7.1.2",
"del-cli": "^7.0.0",
"esbuild": ">=0.17.0",
"eslint": "^9.29.0",
"eslint-config-webpack": "^4.5.0",
"execa": "^9.6.1",
Expand Down
4 changes: 4 additions & 0 deletions packages/webpack-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,15 @@
"@types/envinfo": "^7.8.1"
},
"peerDependencies": {
"esbuild": ">=0.17.0",
"webpack": "^5.101.0",
"webpack-bundle-analyzer": "^4.0.0 || ^5.0.0",
"webpack-dev-server": "^5.0.0"
},
"peerDependenciesMeta": {
"esbuild": {
"optional": true
},
"webpack-bundle-analyzer": {
"optional": true
},
Expand Down
167 changes: 162 additions & 5 deletions packages/webpack-cli/src/webpack-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1953,7 +1953,7 @@ class WebpackCLI {

async run(args: readonly string[], parseOptions: ParseOptions) {
// Default `--color` and `--no-color` options
// eslint-disable-next-line @typescript-eslint/no-this-alias

const self: WebpackCLI = this;

// Register own exit
Expand Down Expand Up @@ -2153,6 +2153,117 @@ class WebpackCLI {
await this.program.parseAsync(args, parseOptions);
}

/**
* Returns `true` for TypeScript config file extensions that should be
* loaded via the esbuild programmatic API rather than through the standard
* eval-import or rechoir paths.
*
* | Extension | Handled by |
* |-----------|-------------------------------|
* | `.ts` | esbuild (this path) |
* | `.tsx` | esbuild (this path) |
* | `.mts` | esbuild (this path) |
* | `.cts` | rechoir (unchanged, CJS-only) |
*
* @param configPath - Path to the webpack configuration file.
* @returns `true` if the file should be loaded via esbuild.
*/
private isTypeScriptConfig(configPath: string): boolean {
const ext = path.extname(configPath).toLowerCase();
return ext === ".ts" || ext === ".tsx" || ext === ".mts";
}

/**
* Loads any TypeScript config file (CJS or ESM syntax) using esbuild as
* the programmatic TypeScript loader.
*
* esbuild is an optional peer dependency. If it is not installed, a clean
* actionable error is shown instead of a raw Node.js internal crash.
*
* @param configPath - Absolute or relative path to the TypeScript config file.
* @returns The module namespace object of the loaded config file.
* @throws {Error & { isMissingLoaderError: true }} When esbuild is not installed.
*/

private async loadTypeScriptConfig(configPath: string): Promise<unknown> {
const resolvedPath = path.resolve(configPath);

let esbuild: typeof import("esbuild");

try {
esbuild = (await import("esbuild" as string)) as typeof import("esbuild");
} catch {
throw Object.assign(new Error("missing-esbuild"), { isMissingLoaderError: true });
}

const configDir = path.dirname(resolvedPath);

// A .ts or .tsx file in a CJS context should be compiled to CJS.
const isCJS = await this.#isCJSTypeScriptConfig(resolvedPath);
const tmpName = `.webpack.config.${Date.now()}.${Math.random().toString(36).slice(2)}.mjs`;
const tmpPath = path.join(configDir, tmpName);

try {
if (isCJS) {
// Compile to CJS first as a text bundle, then wrap it in an ESM
const result = await esbuild.build({
entryPoints: [resolvedPath],
write: false,
bundle: true,
format: "cjs",
platform: "node",
target: `node${process.versions.node.split(".")[0]}`,
sourcemap: false,
logLevel: "silent",
packages: "external",
});
const cjsCode = result.outputFiles[0].text;
const wrapped = ` import { createRequire } from "node:module";
import { fileURLToPath as _fup } from "node:url";
import _path from "node:path";

const __filename = _fup(import.meta.url);
const __dirname = _path.dirname(__filename);
const require = createRequire(import.meta.url);

const module = { exports: {} };
const exports = module.exports;

${cjsCode}

export default module.exports;`;

await fs.promises.writeFile(tmpPath, wrapped, "utf8");
} else {
// Pure ESM TypeScript config, original path, unchanged.
await esbuild.build({
entryPoints: [resolvedPath],
outfile: tmpPath,
bundle: false,
format: "esm",
platform: "node",
target: `node${process.versions.node.split(".")[0]}`,
sourcemap: false,
logLevel: "silent",
});
}

return await import(pathToFileURL(tmpPath).toString());
} finally {
// eslint-disable-next-line @typescript-eslint/no-empty-function
fs.promises.unlink(tmpPath).catch(() => {});
}
}

async #isCJSTypeScriptConfig(configPath: string): Promise<boolean> {
try {
const src = await fs.promises.readFile(configPath, "utf8");
return /\brequire\s*\(|\bmodule\.exports\b/.test(src);
} catch {
return false;
}
}

async loadConfig(options: Options) {
const disableInterpret =
typeof options.disableInterpret !== "undefined" && options.disableInterpret;
Expand All @@ -2164,6 +2275,7 @@ class WebpackCLI {
let options: LoadableWebpackConfiguration | undefined;

const isFileURL = configPath.startsWith("file://");
const normalizedPath = isFileURL ? fileURLToPath(configPath) : configPath;

try {
let loadingError;
Expand All @@ -2172,7 +2284,7 @@ class WebpackCLI {
options = // eslint-disable-next-line no-eval
(await eval(`import("${isFileURL ? configPath : pathToFileURL(configPath)}")`)).default;
} catch (err) {
if (this.isValidationError(err) || process.env?.WEBPACK_CLI_FORCE_LOAD_ESM_CONFIG) {
if (this.isValidationError(err)) {
throw err;
}

Expand All @@ -2181,6 +2293,12 @@ class WebpackCLI {

// Fallback logic when we can't use `import(...)`
if (loadingError) {
if (process.env?.WEBPACK_CLI_FORCE_LOAD_ESM_CONFIG) {
throw new ConfigurationLoadingError([
loadingError,
new Error("require() skipped: WEBPACK_CLI_FORCE_LOAD_ESM_CONFIG is set"),
]);
}
const { jsVariants, extensions } = await import("interpret");
const ext = path.extname(configPath).toLowerCase();

Expand All @@ -2197,6 +2315,17 @@ class WebpackCLI {
rechoir.prepare(extensions, configPath);
} catch (error) {
if ((error as RechoirError)?.failures) {
if (this.isTypeScriptConfig(normalizedPath)) {
// Re-throw so the outer catch can attempt the esbuild fallback.
throw new ConfigurationLoadingError([
loadingError!,
new Error(
`Unable to use specified module loaders for "${path.extname(normalizedPath)}".`,
),
]);
}

// Non-TypeScript file, original rechoir output unchanged.
this.logger.error(`Unable load '${configPath}'`);
this.logger.error((error as RechoirError).message);
for (const failure of (error as RechoirError).failures) {
Expand Down Expand Up @@ -2234,14 +2363,42 @@ class WebpackCLI {
options = {};
}
} catch (error) {
if (error instanceof ConfigurationLoadingError) {
if (error instanceof ConfigurationLoadingError && this.isTypeScriptConfig(normalizedPath)) {
try {
const mod = await this.loadTypeScriptConfig(normalizedPath);
options =
(mod as { default?: LoadableWebpackConfiguration }).default ??
(mod as LoadableWebpackConfiguration);
} catch (err) {
if ((err as Error & { isMissingLoaderError?: boolean }).isMissingLoaderError) {
this.logger.error(
"Cannot load a TypeScript webpack config without a TypeScript loader.",
);
this.logger.error(this.colors.white("Please install esbuild in your project:"));
this.logger.error(this.colors.yellow(" npm install -D esbuild"));
this.logger.error(
this.colors.white("Or use another TypeScript loader via Node.js options."),
);
this.logger.error(
this.colors.blue(
" https://webpack.js.org/guides/typescript/#ways-to-use-typescript-in-webpackconfigts",
),
);
} else {
// esbuild was found but the config itself has an error (syntax etc.)
this.logger.error(`Failed to load '${configPath}' config`);
this.logger.error(err);
}
process.exit(2);
}
} else if (error instanceof ConfigurationLoadingError) {
this.logger.error(`Failed to load '${configPath}' config\n${error.message}`);
process.exit(2);
} else {
this.logger.error(`Failed to load '${configPath}' config`);
this.logger.error(error);
process.exit(2);
}

process.exit(2);
}

if (Array.isArray(options)) {
Expand Down
11 changes: 2 additions & 9 deletions test/build/config-format/typescript-auto/typescript.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,8 @@ describe("typescript configuration", () => {
});

/* eslint-disable jest/no-standalone-expect */

if (major >= 22) {
// No `type` in `the package.json` but Node.js support `require` ECMA modules
expect(stderr).toContain(
"Reparsing as ES module because module syntax was detected. This incurs a performance overhead.",
);
} else {
expect(stderr).toBeFalsy();
}
// esbuild handles transpilation cleanly — no Node.js "Reparsing" warning.
expect(stderr).toBeFalsy();

expect(stdout).toBeTruthy();
expect(exitCode).toBe(0);
Expand Down
1 change: 1 addition & 0 deletions test/build/config-format/typescript-esbuild/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log("Rimuru Tempest");
76 changes: 76 additions & 0 deletions test/build/config-format/typescript-esbuild/typescript.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { run } from "../../../utils/test-utils.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

describe("typescript configuration via esbuild fallback", () => {
it("should load a .ts config with ESM syntax without nodeOptions", async () => {
const { exitCode, stderr, stdout } = await run(__dirname, ["-c", "./webpack.config.ts"]);

expect(stderr).toBeFalsy();
expect(stdout).toBeTruthy();
expect(exitCode).toBe(0);
expect(existsSync(resolve(__dirname, "dist/foo.bundle.js"))).toBeTruthy();
});

it("should load a .ts config with CJS syntax without nodeOptions", async () => {
const { exitCode, stderr, stdout } = await run(__dirname, ["-c", "./webpack.config.cjs.ts"]);

expect(stderr).toBeFalsy();
expect(stdout).toBeTruthy();
expect(exitCode).toBe(0);
expect(existsSync(resolve(__dirname, "dist/foo.bundle.js"))).toBeTruthy();
});

it("should load a .ts config with CJS syntax (require/module.exports) without nodeOptions", async () => {
const { exitCode, stderr, stdout } = await run(__dirname, [
"-c",
"./webpack.config.cjs-require.ts",
]);

expect(stderr).toBeFalsy();
expect(stdout).toBeTruthy();
expect(exitCode).toBe(0);
expect(existsSync(resolve(__dirname, "dist/foo.bundle.js"))).toBeTruthy();
});

it("should load a .ts config exporting a function via module.exports without nodeOptions", async () => {
const { exitCode, stderr, stdout } = await run(__dirname, ["-c", "./webpack.config.cjs-fn.ts"]);

expect(stderr).toBeFalsy();
expect(stdout).toBeTruthy();
expect(exitCode).toBe(0);
expect(existsSync(resolve(__dirname, "dist/foo.bundle.js"))).toBeTruthy();
});

it("should load a .mts config without nodeOptions", async () => {
const { exitCode, stderr, stdout } = await run(__dirname, ["-c", "./webpack.config.mts"]);

expect(stderr).toBeFalsy();
expect(stdout).toBeTruthy();
expect(exitCode).toBe(0);
expect(existsSync(resolve(__dirname, "dist/foo.bundle.js"))).toBeTruthy();
});

it("should show a clean actionable error when esbuild is not installed", async () => {
const { exitCode, stderr } = await run(__dirname, ["-c", "./webpack.config.ts"], {
env: {
...process.env,
// Force rechoir to fail and esbuild resolution to fail.
NODE_PATH: "/nonexistent_xyz",
WEBPACK_CLI_FORCE_LOAD_ESM_CONFIG: "1",
},
});

if (exitCode !== 0) {
expect(stderr).toContain("npm install -D esbuild");
expect(stderr).not.toContain("Cannot require() ES Module");
expect(stderr).not.toContain("Unknown file extension");
expect(stderr).not.toContain("This is caused by either a bug in Node.js");
expect(stderr).not.toContain(" at ");
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const path = require("node:path");

// cspell:ignore elopment
module.exports = () => ({
mode: "development",
entry: "./main.ts",
output: {
path: path.resolve(__dirname, "dist"),
filename: "foo.bundle.js",
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const path = require("node:path");

// cspell:ignore elopment
const config = {
mode: "development",
entry: "./main.ts",
output: {
path: path.resolve(__dirname, "dist"),
filename: "foo.bundle.js",
},
};

module.exports = config;
16 changes: 16 additions & 0 deletions test/build/config-format/typescript-esbuild/webpack.config.cjs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const path = require("node:path");

/* eslint-disable no-useless-concat */

// cspell:ignore elopment
const mode: string = "dev" + "elopment";
const config = {
mode,
entry: "./main.ts",
output: {
path: path.resolve("dist"),
filename: "foo.bundle.js",
},
};

module.exports = config;
Loading
Loading