Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
esbuild-loader
Speed up your Webpack build with [esbuild](https://github.com/evanw/esbuild)! 🔥
[_esbuild_](https://github.com/evanw/esbuild) is a JavaScript bundler written in Go that supports blazing fast ESNext & TypeScript transpilation and [JS minification](https://github.com/privatenumber/minification-benchmarks/).
[_esbuild-loader_](https://github.com/privatenumber/esbuild-loader) lets you harness the speed of esbuild in your Webpack build by offering faster alternatives for transpilation (eg. `babel-loader`/`ts-loader`) and minification (eg. Terser)!
> [!TIP]
> **Are you using TypeScript with Node.js?**
>
> Supercharge your Node.js with TypeScript support using _tsx_!
>
> _tsx_ is a simple, lightweight, and blazing fast alternative to ts-node.
>
> [→ Learn more about _tsx_](https://github.com/privatenumber/tsx)
Already a sponsor? Join the discussion in the Development repo!
## 🚀 Install
```bash
npm i -D esbuild-loader
```
## 🚦 Quick Setup
To leverage `esbuild-loader` in your Webpack configuration, add a new rule for `esbuild-loader` matching the files you want to transform, such as `.js`, `.jsx`, `.ts`, or `.tsx`. Make sure to remove any other loaders you were using before (e.g. `babel-loader`/`ts-loader`).
Here's an example of how to set it up in your `webpack.config.js`:
```diff
module.exports = {
module: {
rules: [
- // Transpile JavaScript
- {
- test: /\.js$/,
- use: 'babel-loader'
- },
-
- // Compile TypeScript
- {
- test: /\.tsx?$/,
- use: 'ts-loader'
- },
+ // Use esbuild to compile JavaScript & TypeScript
+ {
+ // Match `.js`, `.jsx`, `.ts` or `.tsx` files
+ test: /\.[jt]sx?$/,
+ loader: 'esbuild-loader',
+ options: {
+ // JavaScript version to compile to
+ target: 'es2015'
+ }
+ },
// Other rules...
],
},
}
```
In this setup, esbuild will automatically determine how to handle each file based on its extension:
- `.js` files will be treated as JS (no JSX allowed)
- `.jsx` as JSX
- `.ts` as TS (no TSX allowed)
- `.tsx` as TSX
If you want to force a specific loader on different file extensions (e.g. to allow JSX in `.js` files), you can use the [`loader` option](https://github.com/privatenumber/esbuild-loader/#loader):
```diff
{
test: /\.js$/,
loader: 'esbuild-loader',
options: {
+ // Treat `.js` files as `.jsx` files
+ loader: 'jsx',
// JavaScript version to transpile to
target: 'es2015'
}
}
```
## Loader
### JavaScript
`esbuild-loader` can be used in-place of `babel-loader` to transpile new JavaScript syntax into code compatible with older JavaScript engines.
While this ensures your code can run smoothly across various environments, note that it can bloat your output code (like Babel).
The default target is `esnext`, which means it doesn't perform any transpilations.
To specify a target JavaScript engine that only supports ES2015, use the following configuration in your `webpack.config.js`:
```diff
{
test: /\.jsx?$/,
loader: 'esbuild-loader',
options: {
+ target: 'es2015',
},
}
```
For a detailed list of supported transpilations and versions, refer to [the esbuild documentation](https://esbuild.github.io/content-types/#javascript).
### TypeScript
`esbuild-loader` can be used in-place of `ts-loader` to compile TypeScript.
```js
({
// `.ts` or `.tsx` files
test: /\.tsx?$/,
loader: 'esbuild-loader'
})
```
> [!IMPORTANT]
> It's possible to use `loader: 'tsx'` for both `.ts` and `.tsx` files, but this could lead to unexpected behavior as TypeScript and TSX do not have compatible syntaxes.
>
> [→ Read more](https://esbuild.github.io/content-types/#ts-vs-tsx)
#### `tsconfig.json`
If you have a `tsconfig.json` file in your project, `esbuild-loader` will automatically load it.
If it's under a custom name, you can pass in the path via `tsconfig` option:
```diff
{
test: /\.tsx?$/,
loader: 'esbuild-loader',
options: {
+ tsconfig: './tsconfig.custom.json',
},
},
```
> Behind the scenes: [`get-tsconfig`](https://github.com/privatenumber/get-tsconfig) is used to load the tsconfig, and to also resolve the `extends` property if it exists.
The `tsconfigRaw` option can be used to pass in a raw `tsconfig` object, but it will not resolve the `extends` property.
##### Caveats
- esbuild only supports a subset of `tsconfig` options [(see `TransformOptions` interface)](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L159-L165).
- Enable [`isolatedModules`](https://www.typescriptlang.org/tsconfig#isolatedModules) to avoid mis-compilation with features like re-exporting types.
- Enable [`esModuleInterop`](https://www.typescriptlang.org/tsconfig/#esModuleInterop) to make TypeScript's type system compatible with ESM imports.
- Features that require type interpretation, such as `emitDecoratorMetadata` and declaration, are not supported.
[→ Read more about TypeScript Caveats](https://esbuild.github.io/content-types/#typescript-caveats)
#### `tsconfig.json` Paths
Use [tsconfig-paths-webpack-plugin](https://github.com/dividab/tsconfig-paths-webpack-plugin) to add support for [`tsconfig.json#paths`](https://www.typescriptlang.org/tsconfig/paths.html).
Since `esbuild-loader` only transforms code, it cannot aid Webpack with resolving paths.
#### Type-checking
esbuild **does not** type check your code. And according to the [esbuild FAQ](https://esbuild.github.io/faq/#:~:text=typescript%20type%20checking%20(just%20run%20tsc%20separately)), it will not be supported.
Consider these type-checking alternatives:
- Using an IDEs like [VSCode](https://code.visualstudio.com/docs/languages/typescript) or [WebStorm](https://www.jetbrains.com/help/webstorm/typescript-support.html) that has live type-checking built in
- Running `tsc --noEmit` to type check
- Integrating type-checking to your Webpack build as a separate process using [`fork-ts-checker-webpack-plugin`](https://github.com/TypeStrong/fork-ts-checker-webpack-plugin)
### Defining constants
Use the [`define`](#define) option to replace global identifiers with constant expressions:
```diff
{
test: /\.[jt]sx?$/,
loader: 'esbuild-loader',
options: {
+ define: {
+ 'process.env.NODE_ENV': JSON.stringify('production'),
+ },
},
}
```
> [!TIP]
> The loader's `define` works with **all devtools**, including eval-based ones. If you're using the [plugin's `define`](#defining-constants-1) and it's not working, try the loader instead.
## EsbuildPlugin
### Minification
Esbuild supports JavaScript minification, offering a faster alternative to traditional JS minifiers like Terser or UglifyJs. Minification is crucial for reducing file size and improving load times in web development. For a comparative analysis of its performance, refer to these [minification benchmarks](https://github.com/privatenumber/minification-benchmarks).
In `webpack.config.js`:
```diff
+ const { EsbuildPlugin } = require('esbuild-loader')
module.exports = {
...,
+ optimization: {
+ minimizer: [
+ new EsbuildPlugin({
+ target: 'es2015' // Syntax to transpile to (see options below for possible values)
+ })
+ ]
+ },
}
```
> [!TIP]
> Utilizing the `target` option allows for the use of newer JavaScript syntax, enhancing minification effectiveness.
### Defining constants
Webpack's [`DefinePlugin`](https://webpack.js.org/plugins/define-plugin/) can replaced with `EsbuildPlugin` to define global constants. This could speed up the build by removing the parsing costs associated with the `DefinePlugin`.
In `webpack.config.js`:
```diff
- const { DefinePlugin } = require('webpack')
+ const { EsbuildPlugin } = require('esbuild-loader')
module.exports = {
// ...,
plugins:[
- new DefinePlugin({
- 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
- })
+ new EsbuildPlugin({
+ define: {
+ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
+ },
+ }),
]
}
```
> [!WARNING]
> The plugin's `define` option **does not work with eval-based devtools** (e.g., `eval`, `eval-source-map`). This is because eval devtools wrap module code in `eval()` strings, and esbuild's define cannot replace identifiers inside string literals. If you need to use `define` with eval devtools, use the [loader's `define` option](#define) instead, which transforms files before bundling.
### Transpilation
If your project does not use TypeScript, JSX, or any other syntax that requires additional configuration beyond what Webpack provides, you can use `EsbuildPlugin` for transpilation instead of the loader.
It will be faster because there's fewer files to process, and will produce a smaller output because polyfills will only be added once for the entire build as opposed to per file.
To utilize esbuild for transpilation, simply set the `target` option on the plugin to specify which syntax support you want.
## CSS Minification
Depending on your setup, there are two ways to minify CSS. You should already have CSS loading setup using [`css-loader`](https://github.com/webpack-contrib/css-loader).
### CSS assets
If the CSS is extracted and emitted as `.css` file, you can replace CSS minification plugins like [`css-minimizer-webpack-plugin`](https://github.com/webpack-contrib/css-minimizer-webpack-plugin) with the `EsbuildPlugin`.
Assuming the CSS is extracted using something like [MiniCssExtractPlugin](https://github.com/webpack-contrib/mini-css-extract-plugin), in `webpack.config.js`:
```diff
const { EsbuildPlugin } = require('esbuild-loader')
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
// ...,
optimization: {
minimizer: [
new EsbuildPlugin({
target: 'es2015',
+ css: true // Apply minification to CSS assets
})
]
},
module: {
rules: [
{
test: /\.css$/i,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
}
],
},
plugins: [
new MiniCssExtractPlugin()
]
}
```
### CSS in JS
If your CSS is not emitted as a `.css` file, but rather injected with JavaScript using something like [`style-loader`](https://github.com/webpack-contrib/style-loader), you can use the loader for minification.
In `webpack.config.js`:
```diff
module.exports = {
// ...,
module: {
rules: [
{
test: /\.css$/i,
use: [
'style-loader',
'css-loader',
+ {
+ loader: 'esbuild-loader',
+ options: {
+ minify: true,
+ },
+ },
],
},
],
},
}
```
## Bring your own esbuild (Advanced)
esbuild-loader comes with a version of esbuild it has been tested to work with. However, [esbuild has a frequent release cadence](https://github.com/evanw/esbuild/releases), and while we try to keep up with the important releases, it can get outdated.
To work around this, you can use the `implementation` option in the loader or the plugin to pass in your own version of esbuild (eg. a newer one).
> [!WARNING]
> ⚠esbuild is not stable yet and can have dramatic differences across releases. Using a different version of esbuild is not guaranteed to work.
```diff
+ const esbuild = require('esbuild')
module.exports = {
// ...,
module: {
rules: [
{
test: ...,
loader: 'esbuild-loader',
options: {
// ...,
+ implementation: esbuild,
},
},
],
},
}
```
## Setup examples
If you'd like to see working Webpack builds that use esbuild-loader for basic JS, React, TypeScript, Next.js, etc. check out the examples repo:
[→ esbuild-loader examples](https://github.com/privatenumber/esbuild-loader-examples)
## ⚙️ Options
### Loader
The loader supports [all Transform options from esbuild](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L158-L172).
Note:
- Source-maps are automatically configured for you via [`devtool`](https://webpack.js.org/configuration/devtool/). `sourcemap`/`sourcefile` options are ignored.
- The root `tsconfig.json` is automatically detected for you. You don't need to pass in [`tsconfigRaw`](https://esbuild.github.io/api/#tsconfig-raw) unless it's in a different path.
Here are some common configurations and custom options:
#### tsconfig
Type: `string`
Pass in the file path to a **custom** tsconfig file. If the file name is `tsconfig.json`, it will automatically detect it.
#### target
Type: `string | Array`
Default: `'es2015'`
The target environment (e.g. `es2016`, `chrome80`, `esnext`).
Read more about it in the [esbuild docs](https://esbuild.github.io/api/#target).
#### loader
Type: `'js' | 'jsx' | 'ts' | 'tsx' | 'css' | 'json' | 'text' | 'base64' | 'file' | 'dataurl' | 'binary' | 'default'`
Default: `'default'`
The loader to use to handle the file. See the type for [possible values](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L3).
By default, it automatically detects the loader based on the file extension.
Read more about it in the [esbuild docs](https://esbuild.github.io/api/#loader).
#### jsxFactory
Type: `string`
Default: `React.createElement`
Customize the JSX factory function name to use.
Read more about it in the [esbuild docs](https://esbuild.github.io/api/#jsx-factory).
#### jsxFragment
Type: `string`
Default: `React.Fragment`
Customize the JSX fragment function name to use.
Read more about it in the [esbuild docs](https://esbuild.github.io/api/#jsx-fragment).
#### define
Type: `{ [key: string]: string }`
Replace global identifiers with constant expressions (e.g., `'process.env.NODE_ENV': JSON.stringify('production')`).
> [!TIP]
> Unlike the plugin's `define` option, the loader's `define` works with **all devtools** including eval-based ones (e.g., `eval-source-map`). This is because the loader transforms files _before_ Webpack bundles them, so identifiers are replaced before any eval wrapping occurs.
Read more about it in the [esbuild docs](https://esbuild.github.io/api/#define).
#### implementation
Type: `{ transform: Function }`
_Custom esbuild-loader option._
Use it to pass in a [different esbuild version](#bring-your-own-esbuild-advanced).
### EsbuildPlugin
The loader supports [all Transform options from esbuild](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L158-L172).
#### target
Type: `string | Array`
Default: `'esnext'`
Target environment (e.g. `'es2016'`, `['chrome80', 'esnext']`)
Read more about it in the [esbuild docs](https://esbuild.github.io/api/#target).
Here are some common configurations and custom options:
#### format
Type: `'iife' | 'cjs' | 'esm'`
Default:
- `iife` if both of these conditions are met:
- Webpack's [`target`](https://webpack.js.org/configuration/target/) is set to `web`
- esbuild's [`target`](#target-1) is not `esnext`
- `undefined` (no format conversion) otherwise
The default is `iife` when esbuild is configured to support a low target, because esbuild injects helper functions at the top of the code. On the web, having functions declared at the top of a script can pollute the global scope. In some cases, this can lead to a variable collision error. By setting `format: 'iife'`, esbuild wraps the helper functions in an [IIFE](https://developer.mozilla.org/en-US/docs/Glossary/IIFE) to prevent them from polluting the global.
Read more about it in the [esbuild docs](https://esbuild.github.io/api/#format).
#### minify
Type: `boolean`
Default: `true`
Enable JS minification. Enables all `minify*` flags below.
To have nuanced control over minification, disable this and enable the specific minification you want below.
Read more about it in the [esbuild docs](https://esbuild.github.io/api/#minify).
#### minifyWhitespace
Type: `boolean`
Minify JS by removing whitespace.
#### minifyIdentifiers
Type: `boolean`
Minify JS by shortening identifiers.
#### minifySyntax
Type: `boolean`
Minify JS using equivalent but shorter syntax.
#### legalComments
Type: `'none' | 'inline' | 'eof' | 'external'`
Default: `'inline'`
Read more about it in the [esbuild docs](https://esbuild.github.io/api/#legal-comments).
#### css
Type: `boolean`
Default: `false`
Whether to minify CSS files.
#### include
Type: `string | RegExp | Array`
To only apply the plugin to certain assets, pass in filters include
#### exclude
Type: `string | RegExp | Array`
To prevent the plugin from applying to certain assets, pass in filters to exclude
#### implementation
Type: `{ transform: Function }`
Use it to pass in a [different esbuild version](#bring-your-own-esbuild-advanced).
## 💡 Support
For personalized assistance, take advantage of my [_Priority Support_ service](https://github.com/sponsors/privatenumber).
Whether it's about Webpack configuration, esbuild, or TypeScript, I'm here to guide you every step of the way!
## 🙋♀️ FAQ
### Is it possible to use esbuild plugins?
No. esbuild plugins are [only available in the build API](https://esbuild.github.io/plugins/#:~:text=plugins%20can%20also%20only%20be%20used%20with%20the%20build%20api%2C%20not%20with%20the%20transform%20api.). And esbuild-loader uses the transform API instead of the build API for two reasons:
1. The build API is for creating JS bundles, which is what Webpack does. If you want to use esbuild's build API, consider using esbuild directly instead of Webpack.
2. The build API reads directly from the file-system, but Webpack loaders operate in-memory. Webpack loaders are essentially just functions that are called with the source-code as the input. Not reading from the file-system allows loaders to be chainable. For example, using `vue-loader` to compile Single File Components (`.vue` files), then using `esbuild-loader` to transpile just the JS part of the SFC.
### Is it possible to use esbuild's [inject](https://esbuild.github.io/api/#inject) option?
No. The `inject` option is only available in the build API. And esbuild-loader uses the transform API.
However, you can use the Webpack equivalent [ProvidePlugin](https://webpack.js.org/plugins/provide-plugin/) instead.
If you're using React, check out [this example](https://github.com/privatenumber/esbuild-loader-examples/blob/52ca91b8cb2080de5fc63cc6e9371abfefe1f823/examples/react/webpack.config.js#L39-L41) on how to auto-import React in your components.
### Is it possible to use Babel plugins?
No. If you really need them, consider porting them over to a Webpack loader.
And please don't chain `babel-loader` and `esbuild-loader`. The speed gains come from replacing `babel-loader`.
### Why am I not getting a [100x speed improvement](https://esbuild.github.io/faq/#benchmark-details) as advertised?
Running esbuild as a standalone bundler vs esbuild-loader + Webpack are completely different:
- esbuild is highly optimized, written in Go, and compiled to native code. Read more about it [here](https://esbuild.github.io/faq/#why-is-esbuild-fast).
- esbuild-loader is handled by Webpack in a JS runtime, which applies esbuild transforms per file. On top of that, there's likely other loaders & plugins in a Webpack config that slow it down.
Using a JS runtime introduces a bottleneck that makes reaching those speeds impossible. However, esbuild-loader can still speed up your build by removing the bottlenecks created by [`babel-loader`](https://twitter.com/wSokra/status/1316274855042584577), `ts-loader`, Terser, etc.
## 💞 Related projects
#### [tsx](https://github.com/esbuild-kit/tsx)
Node.js enhanced with esbuild to run TypeScript and ESM.
#### [instant-mocha](https://github.com/privatenumber/instant-mocha)
Webpack-integrated Mocha test-runner with Webpack 5 support.
#### [webpack-localize-assets-plugin](https://github.com/privatenumber/webpack-localize-assets-plugin)
Localize/i18nalize your Webpack build. Optimized for multiple locales!
## Sponsors
================================================
FILE: package.json
================================================
{
"name": "esbuild-loader",
"version": "0.0.0-semantic-release",
"description": "⚡️ Speed up your Webpack build with esbuild",
"keywords": [
"esbuild",
"webpack",
"loader",
"typescript",
"esnext"
],
"license": "MIT",
"repository": "privatenumber/esbuild-loader",
"funding": "https://github.com/privatenumber/esbuild-loader?sponsor=1",
"author": {
"name": "Hiroki Osame",
"email": "hiroki.osame@gmail.com"
},
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.cjs",
"types": "./dist/index.d.cts",
"exports": {
".": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
},
"./package.json": "./package.json"
},
"imports": {
"#esbuild-loader": {
"types": "./src/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.cjs"
}
},
"packageManager": "pnpm@10.26.1",
"scripts": {
"build": "pkgroll --target=node16.19.0",
"test": "tsx --env-file=.env tests",
"dev": "tsx watch --env-file=.env --conditions=development tests",
"lint": "lintroll --cache .",
"type-check": "tsc --noEmit",
"prepack": "pnpm build && clean-pkg-json",
"prepare": "skills-npm"
},
"dependencies": {
"esbuild": "^0.27.1",
"get-tsconfig": "^4.10.1",
"loader-utils": "^2.0.4",
"webpack-sources": "^3.3.4"
},
"peerDependencies": {
"webpack": "^4.40.0 || ^5.0.0"
},
"devDependencies": {
"@types/loader-utils": "^2.0.6",
"@types/mini-css-extract-plugin": "2.4.0",
"@types/node": "^22.19.3",
"@types/webpack": "^4.41.40",
"@types/webpack-sources": "^3.2.3",
"clean-pkg-json": "^1.3.0",
"css-loader": "^5.2.7",
"execa": "^8.0.1",
"fs-fixture": "^2.11.0",
"lintroll": "^1.20.1",
"manten": "^2.0.0",
"memfs": "^4.56.11",
"mini-css-extract-plugin": "^1.6.2",
"pkgroll": "^2.17.0",
"skills-npm": "^1.0.0",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"webpack": "^4.47.0",
"webpack-cli": "^4.10.0",
"webpack-merge": "^6.0.1",
"webpack-test-utils": "^2.1.0",
"webpack5": "npm:webpack@^5.0.0"
},
"pnpm": {
"overrides": {
"fsevents@1": "^2.0.0"
}
}
}
================================================
FILE: src/@types/webpack-module-filename-helpers.d.ts
================================================
declare module 'webpack/lib/ModuleFilenameHelpers.js' {
type Filter = string | RegExp;
type FilterObject = {
test?: Filter | Filter[];
include?: Filter | Filter[];
exclude?: Filter | Filter[];
};
export const matchObject: (filterObject: FilterObject, stringToCheck: string) => boolean;
}
================================================
FILE: src/@types/webpack.d.ts
================================================
import 'webpack';
import type { LoaderContext as Webpack5LoaderContext } from 'webpack5';
declare module 'webpack' {
namespace compilation {
interface Compilation {
getAssets(): Asset[];
emitAsset(
file: string,
source: Source,
assetInfo?: AssetInfo,
): void;
}
}
namespace loader {
interface LoaderContext {
getOptions: Webpack5LoaderContext['getOptions'];
}
}
interface AssetInfo {
minimized?: boolean;
}
}
================================================
FILE: src/index.d.ts
================================================
import type { EsbuildPluginOptions } from './types.js';
export class EsbuildPlugin {
constructor(options?: EsbuildPluginOptions);
apply(): void;
}
export * from './types.js';
================================================
FILE: src/index.ts
================================================
import esbuildLoader from './loader.js';
import EsbuildPlugin from './plugin.js';
export default esbuildLoader;
export { EsbuildPlugin };
================================================
FILE: src/loader.ts
================================================
import path from 'path';
import {
transform as defaultEsbuildTransform,
type TransformOptions,
} from 'esbuild';
import { getOptions } from 'loader-utils';
import type webpack from 'webpack';
import {
getTsconfig,
parseTsconfig,
type TsConfigResult,
} from 'get-tsconfig';
import type { LoaderOptions } from './types.js';
const tsconfigCache = new Map();
const tsExtensionsPattern = /\.(?:[cm]?ts|[tj]sx)$/;
async function ESBuildLoader(
this: webpack.loader.LoaderContext,
source: string,
): Promise {
const done = this.async()!;
const options: LoaderOptions = typeof this.getOptions === 'function' ? this.getOptions() : getOptions(this);
const {
implementation,
tsconfig: tsconfigPath,
...esbuildTransformOptions
} = options;
if (implementation && typeof implementation.transform !== 'function') {
done(
new TypeError(
`esbuild-loader: options.implementation.transform must be an ESBuild transform function. Received ${typeof implementation.transform}`,
),
);
return;
}
const transform = implementation?.transform ?? defaultEsbuildTransform;
const { resourcePath } = this;
const transformOptions = {
...esbuildTransformOptions,
target: options.target ?? 'es2015',
loader: options.loader ?? 'default',
sourcemap: this.sourceMap,
sourcefile: resourcePath,
};
const isDependency = resourcePath.includes(`${path.sep}node_modules${path.sep}`);
if (
!('tsconfigRaw' in transformOptions)
// If file is local project, always try to apply tsconfig.json (e.g. allowJs)
// If file is dependency, only apply tsconfig.json if .ts
&& (!isDependency || tsExtensionsPattern.test(resourcePath))
) {
/**
* If a tsconfig.json path is specified, force apply it
* Same way a provided tsconfigRaw is applied regardless
* of whether it actually matches
*
* This follows TypeScript behavior: a tsconfig applies to all
* files imported by entry files, not just files matching include patterns.
* The include/files patterns only determine entry points.
*/
if (!isDependency && tsconfigPath) {
const tsconfigFullPath = path.resolve(tsconfigPath);
const cacheKey = `esbuild-loader:${tsconfigFullPath}`;
let tsconfig = tsconfigCache.get(cacheKey);
if (!tsconfig) {
tsconfig = {
config: parseTsconfig(tsconfigFullPath),
path: tsconfigFullPath,
};
tsconfigCache.set(cacheKey, tsconfig);
}
transformOptions.tsconfigRaw = tsconfig.config as TransformOptions['tsconfigRaw'];
} else {
/* Detect tsconfig file */
let tsconfig;
try {
// Webpack shouldn't be loading the same path multiple times so doesn't need to be cached
tsconfig = getTsconfig(resourcePath, 'tsconfig.json');
} catch (error) {
if (error instanceof Error) {
const tsconfigError = new Error(`[esbuild-loader] Error parsing tsconfig.json:\n${error.message}`);
if (isDependency) {
this.emitWarning(tsconfigError);
} else {
return done(tsconfigError);
}
}
}
if (tsconfig) {
transformOptions.tsconfigRaw = tsconfig.config as TransformOptions['tsconfigRaw'];
}
}
}
/**
* Enable dynamic import by default to support code splitting in Webpack
*/
transformOptions.supported = {
'dynamic-import': true,
...transformOptions.supported,
};
try {
const { code, map } = await transform(source, transformOptions);
done(null, code, map && JSON.parse(map));
} catch (error: unknown) {
done(error as Error);
}
}
export default ESBuildLoader;
================================================
FILE: src/plugin.ts
================================================
import { transform as defaultEsbuildTransform } from 'esbuild';
import {
RawSource as WP4RawSource,
SourceMapSource as WP4SourceMapSource,
} from 'webpack-sources';
import type webpack4 from 'webpack';
import type webpack5 from 'webpack5';
import ModuleFilenameHelpers from 'webpack/lib/ModuleFilenameHelpers.js';
import { version } from '../package.json';
import type { EsbuildPluginOptions } from './types.js';
type Compiler = webpack4.Compiler | webpack5.Compiler;
type Compilation = webpack4.compilation.Compilation | webpack5.Compilation;
type Asset = webpack4.compilation.Asset | Readonly;
type EsbuildTransform = typeof defaultEsbuildTransform;
const isJsFile = /\.[cm]?js(?:\?.*)?$/i;
const isCssFile = /\.css(?:\?.*)?$/i;
const pluginName = 'EsbuildPlugin';
const transformAssets = async (
options: EsbuildPluginOptions,
transform: EsbuildTransform,
compilation: Compilation,
useSourceMap: boolean,
) => {
const { compiler } = compilation;
const sources = 'webpack' in compiler && compiler.webpack.sources;
const SourceMapSource = (sources ? sources.SourceMapSource : WP4SourceMapSource);
const RawSource = (sources ? sources.RawSource : WP4RawSource);
const {
css: minifyCss,
include,
exclude,
implementation,
...transformOptions
} = options;
const minimized = (
transformOptions.minify
|| transformOptions.minifyWhitespace
|| transformOptions.minifyIdentifiers
|| transformOptions.minifySyntax
);
const assets = (compilation.getAssets() as Asset[]).filter(asset => (
// Filter out already minimized
!asset.info.minimized
// Filter out by file type
&& (
isJsFile.test(asset.name)
|| (minifyCss && isCssFile.test(asset.name))
)
&& ModuleFilenameHelpers.matchObject(
{
include,
exclude,
},
asset.name,
)
));
await Promise.all(assets.map(async (asset) => {
const assetIsCss = isCssFile.test(asset.name);
let source: string | Buffer | ArrayBuffer;
let map = null;
if (useSourceMap) {
if (asset.source.sourceAndMap) {
const sourceAndMap = asset.source.sourceAndMap();
source = sourceAndMap.source;
map = sourceAndMap.map;
} else {
source = asset.source.source();
if (asset.source.map) {
map = asset.source.map();
}
}
} else {
source = asset.source.source();
}
const sourceAsString = source.toString();
const result = await transform(sourceAsString, {
...transformOptions,
loader: (
assetIsCss
? 'css'
: transformOptions.loader
),
sourcemap: useSourceMap,
sourcefile: asset.name,
});
if (result.legalComments) {
compilation.emitAsset(
`${asset.name}.LEGAL.txt`,
new RawSource(result.legalComments) as webpack5.sources.Source,
);
}
compilation.updateAsset(
asset.name,
(
// @ts-expect-error complex webpack union type for source
result.map
? new SourceMapSource(
result.code,
asset.name,
result.map,
sourceAsString,
// @ts-expect-error webpack types use Object, not RawSourceMap
map ?? undefined,
true,
)
: new RawSource(result.code)
),
{
...asset.info,
minimized,
},
);
}));
};
export default class EsbuildPlugin {
options: EsbuildPluginOptions;
constructor(
options: EsbuildPluginOptions = {},
) {
const { implementation } = options;
if (
implementation
&& typeof implementation.transform !== 'function'
) {
throw new TypeError(
`[${pluginName}] implementation.transform must be an esbuild transform function. Received ${typeof implementation.transform}`,
);
}
this.options = options;
}
apply(compiler: Compiler) {
const {
implementation,
...options
} = this.options;
const transform = implementation?.transform ?? defaultEsbuildTransform;
if (!('format' in options)) {
const { target } = compiler.options;
const isWebTarget = (
Array.isArray(target)
? target.includes('web')
: target === 'web'
);
const wontGenerateHelpers = !options.target || (
Array.isArray(options.target)
? (
options.target.length === 1
&& options.target[0] === 'esnext'
)
: options.target === 'esnext'
);
if (isWebTarget && !wontGenerateHelpers) {
options.format = 'iife';
}
}
/**
* Enable minification by default if used in the minimizer array
* unless further specified in the options
*/
const usedAsMinimizer = compiler.options.optimization?.minimizer?.includes?.(this);
if (
usedAsMinimizer
&& !(
'minify' in options
|| 'minifyWhitespace' in options
|| 'minifyIdentifiers' in options
|| 'minifySyntax' in options
)
) {
options.minify = compiler.options.optimization?.minimize;
}
/**
* Warn if `define` is used with eval-based devtools.
* Eval devtools wrap module code in eval() strings, which prevents
* esbuild's define from replacing identifiers (they become string content).
*/
const { devtool } = compiler.options;
const isEvalDevtool = (
typeof devtool === 'string'
&& devtool.includes('eval')
);
let hasWarnedEvalDefine = false;
compiler.hooks.compilation.tap(pluginName, (compilation) => {
if (
!hasWarnedEvalDefine
&& options.define
&& Object.keys(options.define).length > 0
&& isEvalDevtool
) {
hasWarnedEvalDefine = true;
const warning = new Error(
`[${pluginName}] The "define" option may not work as expected with eval-based devtools (current: "${devtool}"). `
+ 'Eval devtools wrap module code in eval() strings, preventing identifier replacement. '
+ 'Consider using the loader\'s "define" option instead, or switch to a non-eval devtool like "source-map".',
);
compilation.warnings.push(warning as webpack5.WebpackError);
}
const meta = JSON.stringify({
name: 'esbuild-loader',
version,
options,
});
compilation.hooks.chunkHash.tap(
pluginName,
(_, hash) => hash.update(meta),
);
/**
* Check if sourcemaps are enabled
* Webpack 4: https://github.com/webpack/webpack/blob/v4.46.0/lib/SourceMapDevToolModuleOptionsPlugin.js#L20
* Webpack 5: https://github.com/webpack/webpack/blob/v5.75.0/lib/SourceMapDevToolModuleOptionsPlugin.js#LL27
*/
let useSourceMap = false;
/**
* `finishModules` hook is called after all the `buildModule` hooks are called,
* which is where the `useSourceMap` flag is set
* https://webpack.js.org/api/compilation-hooks/#finishmodules
*/
compilation.hooks.finishModules.tap(
pluginName,
(modules) => {
const firstModule = (
Array.isArray(modules)
? modules[0]
: (modules as Set).values().next().value as webpack5.Module
);
if (firstModule) {
useSourceMap = firstModule.useSourceMap;
}
},
);
// Webpack 5
if ('processAssets' in compilation.hooks) {
compilation.hooks.processAssets.tapPromise(
{
name: pluginName,
// @ts-expect-error undefined on Function type
stage: compilation.constructor.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
additionalAssets: true,
},
() => transformAssets(options, transform, compilation, useSourceMap),
);
compilation.hooks.statsPrinter.tap(pluginName, (statsPrinter) => {
statsPrinter.hooks.print
.for('asset.info.minimized')
.tap(
pluginName,
(
minimized,
{ green, formatFlag },
// @ts-expect-error type incorrectly doesn't accept undefined
) => (
minimized
// @ts-expect-error type incorrectly doesn't accept undefined
? green(formatFlag('minimized'))
: undefined
),
);
});
} else {
compilation.hooks.optimizeChunkAssets.tapPromise(
pluginName,
() => transformAssets(options, transform, compilation, useSourceMap),
);
}
});
}
}
================================================
FILE: src/types.ts
================================================
import type { transform, TransformOptions } from 'esbuild';
type Filter = string | RegExp;
type Implementation = {
transform: typeof transform;
};
type Except = {
[Key in keyof ObjectType as (Key extends Properties ? never : Key)]: ObjectType[Key];
};
export type LoaderOptions = Except & {
/** Pass a custom esbuild implementation */
implementation?: Implementation;
/**
* Path to tsconfig.json file
*/
tsconfig?: string;
};
export type EsbuildPluginOptions = Except & {
include?: Filter | Filter[];
exclude?: Filter | Filter[];
css?: boolean;
/** Pass a custom esbuild implementation */
implementation?: Implementation;
};
================================================
FILE: tests/fixtures.ts
================================================
export const exportFile = (
name: string,
code: string,
) => ({
'/src/index.js': `export { default } from "./${name}"`,
[`/src/${name}`]: code,
});
const trySyntax = (
name: string,
code: string,
) => `
(() => {
try {
${code}
return ${JSON.stringify(name)};
} catch (error) {
return error;
}
})()
`;
export const js = exportFile(
'js.js',
`export default [${[
trySyntax(
'es2016 - Exponentiation operator',
'10 ** 4',
),
trySyntax(
'es2017 - Async functions',
'typeof (async () => {})',
),
// trySyntax(
// 'es2018 - Asynchronous iteration',
// 'for await (let x of []) {}',
// ),
trySyntax(
'es2018 - Spread properties',
'let x = {...Object}',
),
trySyntax(
'es2018 - Rest properties',
'let {...x} = Object',
),
trySyntax(
'es2019 - Optional catch binding',
'try {} catch {}',
),
trySyntax(
'es2020 - Optional chaining',
'Object?.keys',
),
trySyntax(
'es2020 - Nullish coalescing',
'Object ?? true',
),
trySyntax(
'es2020 - import.meta',
'import.meta',
),
trySyntax(
'es2021 - Logical assignment operators',
'let a = false; a ??= true; a ||= true; a &&= true;',
),
trySyntax(
'es2022 - Class instance fields',
'(class { x })',
),
trySyntax(
'es2022 - Static class fields',
'(class { static x })',
),
trySyntax(
'es2022 - Private instance methods',
'(class { #x() {} })',
),
trySyntax(
'es2022 - Private instance fields',
'(class { #x })',
),
trySyntax(
'es2022 - Private static methods',
'(class { static #x() {} })',
),
trySyntax(
'es2022 - Private static fields',
'(class { static #x })',
),
// trySyntax(
// 'es2022 - Ergonomic brand checks',
// '(class { #brand; static isC(obj) { return try obj.#brand; } })',
// ),
trySyntax(
'es2022 - Class static blocks',
'(class { static {} })',
),
// trySyntax(
// 'esnext - Import assertions',
// 'import "x" assert {}',
// ),
].join(',')}];`,
);
export const ts = exportFile(
'ts.ts',
`
import type {Type} from 'foo'
interface Foo {}
type Foo = number
declare module 'foo' {}
enum BasicEnum {
Left,
Right,
}
enum NamedEnum {
SomeEnum = 'some-value',
}
export const a = BasicEnum.Left;
export const b = NamedEnum.SomeEnum;
export default function foo(): string {
return 'foo'
}
// For "ts as tsx" test
const bar = (value: T) => fn();
`,
);
export const blank = {
'/src/index.js': '',
};
export const minification = {
'/src/index.js': 'export default ( stringVal ) => { return stringVal }',
};
export const define = {
'/src/index.js': 'export default () => [__TEST1__, __TEST2__]',
};
export const getHelpers = {
'/src/index.js': 'export default async () => {}',
};
export const legalComments = {
'/src/index.js': `
//! legal comment
globalCall();
`,
};
export const css = {
'/src/index.js': 'import "./styles.css"',
'/src/styles.css': `
div {
color: red;
}
span {
margin: 0px 10px;
}
`,
};
================================================
FILE: tests/index.ts
================================================
import { describe } from 'manten';
import webpack4 from 'webpack';
import webpack5 from 'webpack5';
import { loader } from './specs/loader.js';
import { plugin } from './specs/plugin.js';
const webpacks = [
webpack4,
webpack5,
];
describe('esbuild-loader', async () => {
for (const webpack of webpacks) {
describe(`Webpack ${webpack.version![0]}`, () => {
loader(webpack);
plugin(webpack);
});
}
await import('./specs/tsconfig.js');
await import('./specs/webpack5.js');
});
================================================
FILE: tests/specs/loader.ts
================================================
import { describe, test, expect } from 'manten';
import { build } from 'webpack-test-utils';
import type webpack4 from 'webpack';
import type webpack5 from 'webpack5';
import {
type Webpack,
configureEsbuildLoader,
configureCssLoader,
} from '../utils.js';
import * as fixtures from '../fixtures.js';
import type { EsbuildPluginOptions } from '#esbuild-loader';
const { exportFile } = fixtures;
export const loader = (webpack: typeof webpack4 | typeof webpack5) => {
describe('Loader', () => {
describe('Error handling', () => {
test('tsx fails to be parsed as ts', async () => {
const built = await build(
exportFile(
'tsx.tsx',
'export default hello world
',
),
(config) => {
configureEsbuildLoader(config, {
test: /\.tsx$/,
options: {
loader: 'ts',
},
});
},
webpack,
);
expect(built.stats.hasErrors()).toBe(true);
const [error] = built.stats.compilation.errors;
expect(error.message).toMatch('Transform failed with 1 error');
});
});
test('transforms syntax', async () => {
const built = await build(
fixtures.js,
configureEsbuildLoader,
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
expect(built.require('/dist')).toStrictEqual([
'es2016 - Exponentiation operator',
'es2017 - Async functions',
'es2018 - Spread properties',
'es2018 - Rest properties',
'es2019 - Optional catch binding',
'es2020 - Optional chaining',
'es2020 - Nullish coalescing',
'es2020 - import.meta',
'es2021 - Logical assignment operators',
'es2022 - Class instance fields',
'es2022 - Static class fields',
'es2022 - Private instance methods',
'es2022 - Private instance fields',
'es2022 - Private static methods',
'es2022 - Private static fields',
'es2022 - Class static blocks',
]);
});
test('transforms TypeScript', async () => {
const built = await build(
fixtures.ts,
(config) => {
configureEsbuildLoader(config, {
test: /\.ts$/,
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
expect(built.require('/dist')()).toBe('foo');
});
test('transforms TSX', async () => {
const built = await build(
exportFile(
'tsx.tsx',
'export default (<>hello world
>)',
),
(config) => {
configureEsbuildLoader(config, {
test: /\.tsx$/,
options: {
jsxFactory: 'Array',
jsxFragment: '"Fragment"',
},
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
expect(built.require('/dist')).toStrictEqual([
'Fragment',
null,
[
'div',
null,
'hello world',
],
]);
});
test('tsconfig', async () => {
const built = await build(
exportFile(
'tsx.tsx',
'export default (hello world
)',
),
(config) => {
configureEsbuildLoader(config, {
test: /\.tsx$/,
options: {
tsconfigRaw: {
compilerOptions: {
jsxFactory: 'Array',
},
},
},
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
expect(built.require('/dist/index.js')).toStrictEqual(['div', null, 'hello world']);
});
describe('implementation', () => {
test('error', async () => {
const runWithImplementation = async (
implementation: EsbuildPluginOptions['implementation'],
) => {
const built = await build(
fixtures.blank,
(config) => {
configureEsbuildLoader(config, {
options: {
implementation,
},
});
},
webpack,
);
expect(built.stats.hasErrors()).toBe(true);
const [error] = built.stats.compilation.errors;
throw error;
};
// @ts-expect-error testing invalid type
await expect(runWithImplementation({})).rejects.toThrow(
'esbuild-loader: options.implementation.transform must be an ESBuild transform function. Received undefined',
);
// @ts-expect-error testing invalid type
await expect(runWithImplementation({ transform: 123 })).rejects.toThrow(
'esbuild-loader: options.implementation.transform must be an ESBuild transform function. Received number',
);
});
test('custom transform function', async () => {
const built = await build(
fixtures.blank,
(config) => {
configureEsbuildLoader(config, {
options: {
implementation: {
transform: async () => ({
code: 'export default "CUSTOM_ESBUILD_IMPLEMENTATION"',
map: '',
warnings: [],
}),
},
},
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const dist = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(dist).toContain('CUSTOM_ESBUILD_IMPLEMENTATION');
});
});
describe('ambigious ts/tsx', () => {
test('ts via tsx', async () => {
const built = await build(
fixtures.ts,
(config) => {
configureEsbuildLoader(config, {
test: /\.tsx?$/,
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
expect(built.require('/dist')()).toBe('foo');
});
test('ts via tsx 2', async () => {
const built = await build(
exportFile(
'ts.ts', `
export default (
l: obj,
options: { [key in obj]: V },
): V => {
return options[l];
};
`,
),
(config) => {
configureEsbuildLoader(config, {
test: /\.tsx?$/,
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
expect(built.require('/dist')('a', { a: 1 })).toBe(1);
});
test('ambiguous ts', async () => {
const built = await build(
exportFile(
'ts.ts',
'export default () => 1/g',
),
(config) => {
configureEsbuildLoader(config, {
test: /\.tsx?$/,
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const dist = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(dist).toContain('(() => 1 < /a>/g)');
});
test('ambiguous tsx', async () => {
const built = await build(
exportFile(
'tsx.tsx',
'export default () => 1/g',
),
(config) => {
configureEsbuildLoader(config, {
test: /\.tsx?$/,
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const dist = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(dist).toContain('React.createElement');
});
});
describe('Source-map', () => {
test('source-map eval', async () => {
const built = await build(
fixtures.js,
(config) => {
configureEsbuildLoader(config);
config.devtool = 'eval-source-map';
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const dist = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(dist).toContain('eval(');
});
test('source-map inline', async () => {
const built = await build(
fixtures.js,
(config) => {
configureEsbuildLoader(config);
config.devtool = 'inline-source-map';
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const dist = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(dist).toContain('sourceMappingURL');
});
test('source-map file', async () => {
const built = await build(
fixtures.js,
(config) => {
configureEsbuildLoader(config);
config.devtool = 'source-map';
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const { assets } = built.stats.compilation;
expect(assets).toHaveProperty(['index.js']);
expect(assets).toHaveProperty(['index.js.map']);
});
test('source-map plugin', async () => {
const built = await build(
fixtures.js,
(config) => {
configureEsbuildLoader(config);
delete config.devtool;
config.plugins!.push(
new webpack.SourceMapDevToolPlugin({}) as Webpack['SourceMapDevToolPlugin'],
);
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const dist = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(dist).toContain('sourceMappingURL');
});
});
test('webpack magic comments', async () => {
const built = await build({
'/src/index.js': `
const chunkA = import(/* webpackChunkName: "named-chunk-foo" */'./chunk-a.js')
const chunkB = import(/* webpackChunkName: "named-chunk-bar" */'./chunk-b.js')
export default async () => (await chunkA).default + (await chunkB).default;
`,
'/src/chunk-a.js': 'export default 1',
'/src/chunk-b.js': 'export default 2',
}, configureEsbuildLoader, webpack);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const { assets } = built.stats.compilation;
expect(assets).toHaveProperty(['index.js']);
expect(assets).toHaveProperty(['named-chunk-foo.js']);
expect(assets).toHaveProperty(['named-chunk-bar.js']);
expect(await built.require('/dist')()).toBe(3);
});
test('CSS minification', async () => {
const built = await build(
fixtures.css,
(config) => {
configureEsbuildLoader(config);
const cssRule = configureCssLoader(config);
cssRule.use.push({
loader: 'esbuild-loader',
options: {
minify: true,
},
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const code = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(code).toContain('div{color:red}');
});
test('Keeps dynamic imports by default', async () => {
const built = await build(
{
'/src/index.js': 'export default async () => (await import("./test2.js")).default',
'/src/test2.js': 'export default "test2"',
},
(config) => {
configureEsbuildLoader(config, { options: { target: 'chrome52' } });
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const { assets } = built.stats.compilation;
expect(assets).toHaveProperty(['index.js']);
// Chunk split because esbuild preserved the dynamic import
expect(Object.keys(assets).length).toBe(2);
expect(await built.require('/dist')()).toBe('test2');
});
test('Dynamic imports can be disabled', async () => {
const built = await build(
{
'/src/index.js': 'export default async () => (await import("./test2.js")).default',
'/src/test2.js': 'export default "test2"',
},
(config) => {
configureEsbuildLoader(config, {
options: {
target: 'chrome52',
supported: { 'dynamic-import': false },
},
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const { assets } = built.stats.compilation;
expect(assets).toHaveProperty(['index.js']);
// No chunk split because esbuild removed the dynamic import
expect(Object.keys(assets).length).toBe(1);
expect(await built.require('/dist')()).toBe('test2');
});
test('define replaces identifiers', async () => {
const built = await build(
{
'/src/index.js': 'export default MY_CONSTANT',
},
(config) => {
configureEsbuildLoader(config, {
options: {
define: {
MY_CONSTANT: JSON.stringify('replaced-value'),
},
},
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
expect(built.require('/dist')).toBe('replaced-value');
});
test('define works with eval devtool in loader', async () => {
const built = await build(
{
'/src/index.js': 'export default MY_CONSTANT',
},
(config) => {
configureEsbuildLoader(config, {
options: {
define: {
MY_CONSTANT: JSON.stringify('works-with-eval'),
},
},
});
config.devtool = 'eval-source-map';
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
// Loader's define works with eval devtools because it runs BEFORE bundling
expect(built.require('/dist')).toBe('works-with-eval');
});
});
};
================================================
FILE: tests/specs/plugin.ts
================================================
import { describe, test, expect } from 'manten';
import { build } from 'webpack-test-utils';
import type webpack4 from 'webpack';
import webpack5 from 'webpack5';
import * as esbuild from 'esbuild';
import { merge } from 'webpack-merge';
import {
type Webpack,
isWebpack4,
configureEsbuildMinifyPlugin,
configureMiniCssExtractPlugin,
} from '../utils.js';
import * as fixtures from '../fixtures.js';
import { EsbuildPlugin, type EsbuildPluginOptions } from '#esbuild-loader';
const assertMinified = (code: string) => {
expect(code).not.toMatch(/\s{2,}/);
expect(code).not.toMatch('stringVal');
expect(code).not.toMatch('return ');
};
const countIife = (code: string) => Array.from(code.matchAll(/\(\(\)=>\{/g)).length;
export const plugin = (webpack: typeof webpack4 | typeof webpack5) => {
const webpackIs4 = isWebpack4(webpack);
describe('Plugin', () => {
describe('Minify JS', () => {
describe('should not minify by default', () => {
test('minimizer', async () => {
const built = await build(
fixtures.minification,
(config) => {
config.optimization = {
minimize: false,
minimizer: [
new EsbuildPlugin(),
],
};
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const exportedFunction = built.require('/dist/');
expect(exportedFunction('hello world')).toBe('hello world');
expect(exportedFunction.toString()).toMatch(/\s{2,}/);
});
test('plugin', async () => {
const built = await build(
fixtures.minification,
(config) => {
config.plugins?.push(new EsbuildPlugin());
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const exportedFunction = built.require('/dist/');
expect(exportedFunction('hello world')).toBe('hello world');
expect(exportedFunction.toString()).toMatch(/\s{2,}/);
});
test('plugin with minimize enabled', async () => {
const built = await build(
fixtures.minification,
(config) => {
config.optimization = {
minimize: true,
// Remove Terser
minimizer: [],
};
config.plugins?.push(new EsbuildPlugin());
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const exportedFunction = built.require('/dist/');
expect(exportedFunction('hello world')).toBe('hello world');
expect(exportedFunction.toString()).toMatch(/\s{2,}/);
});
});
test('minify', async () => {
const built = await build(
fixtures.minification,
(config) => {
configureEsbuildMinifyPlugin(config);
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const exportedFunction = built.require('/dist/');
expect(exportedFunction('hello world')).toBe('hello world');
assertMinified(exportedFunction.toString());
});
test('minifyWhitespace', async () => {
const built = await build(
fixtures.minification,
(config) => {
configureEsbuildMinifyPlugin(config, {
minifyWhitespace: true,
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const exportedFunction = built.require('/dist/');
expect(exportedFunction('hello world')).toBe('hello world');
const code = exportedFunction.toString();
expect(code).not.toMatch(/\s{2,}/);
expect(code).toMatch('stringVal');
expect(code).toMatch('return ');
});
test('minifyIdentifiers', async () => {
const built = await build(
fixtures.minification,
(config) => {
configureEsbuildMinifyPlugin(config, {
minifyIdentifiers: true,
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const exportedFunction = built.require('/dist/');
expect(exportedFunction('hello world')).toBe('hello world');
const code = exportedFunction.toString();
expect(code).toMatch(/\s{2,}/);
expect(code).not.toMatch('stringVal');
expect(code).toMatch('return ');
});
test('minifySyntax', async () => {
const built = await build(
fixtures.minification,
(config) => {
configureEsbuildMinifyPlugin(config, {
minifySyntax: true,
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const exportedFunction = built.require('/dist/');
expect(exportedFunction('hello world')).toBe('hello world');
const code = exportedFunction.toString();
expect(code).toMatch(/\s/);
expect(code).toMatch('stringVal');
expect(code).not.toMatch('return ');
});
test('should minify when used alongside plugin', async () => {
const built = await build(
fixtures.minification,
(config) => {
configureEsbuildMinifyPlugin(config);
config.plugins?.push(new EsbuildPlugin());
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const exportedFunction = built.require('/dist/');
expect(exportedFunction('hello world')).toBe('hello world');
assertMinified(exportedFunction.toString());
});
test('minify chunks & filter using include/exclude', async () => {
const built = await build({
'/src/index.js': `
const foo = import(/* webpackChunkName: "named-chunk-foo" */'./foo.js')
const bar = import(/* webpackChunkName: "named-chunk-bar" */'./bar.js')
const baz = import(/* webpackChunkName: "named-chunk-baz" */'./baz.js')
export default [foo, bar, baz];
`,
'/src/foo.js': fixtures.minification['/src/index.js'],
'/src/bar.js': fixtures.minification['/src/index.js'],
'/src/baz.js': fixtures.minification['/src/index.js'],
}, (config) => {
configureEsbuildMinifyPlugin(config, {
include: /ba./,
exclude: /baz/,
});
}, webpack);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const chunkFoo = built.fs.readFileSync('/dist/named-chunk-foo.js', 'utf8').toString();
// The string "__webpack_require__" is only present in unminified chunks
expect(chunkFoo).toContain('__webpack_require__');
const chunkBar = built.fs.readFileSync('/dist/named-chunk-bar.js', 'utf8').toString();
expect(chunkBar).not.toContain('__webpack_require__');
assertMinified(chunkBar);
const chunkBaz = built.fs.readFileSync('/dist/named-chunk-baz.js', 'utf8').toString();
expect(chunkBaz).toContain('__webpack_require__');
});
describe('devtool', () => {
test('minify w/ no devtool', async () => {
const built = await build(
fixtures.blank,
(config) => {
delete config.devtool;
configureEsbuildMinifyPlugin(config);
},
webpack,
);
const { stats } = built;
expect(stats.hasWarnings()).toBe(false);
expect(stats.hasErrors()).toBe(false);
expect(
Object.keys(stats.compilation.assets).length,
).toBe(1);
const file = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(file).not.toContain('//# sourceURL');
});
test('minify w/ devtool inline-source-map', async () => {
const built = await build(
fixtures.blank,
(config) => {
config.devtool = 'inline-source-map';
configureEsbuildMinifyPlugin(config);
},
webpack,
);
const { stats } = built;
expect(stats.hasWarnings()).toBe(false);
expect(stats.hasErrors()).toBe(false);
expect(
Object.keys(stats.compilation.assets).length,
).toBe(1);
const file = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(file).toContain('//# sourceMappingURL=data:application/');
});
test('minify w/ devtool source-map', async () => {
const built = await build(
fixtures.blank,
(config) => {
config.devtool = 'source-map';
configureEsbuildMinifyPlugin(config);
},
webpack,
);
const { stats } = built;
expect(stats.hasWarnings()).toBe(false);
expect(stats.hasErrors()).toBe(false);
expect(
Object.keys(stats.compilation.assets),
).toStrictEqual([
'index.js',
'index.js.map',
]);
const file = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(file).toContain('//# sourceMappingURL=index.js.map');
});
test('minify w/ source-map option and source-map plugin inline', async () => {
const built = await build(
fixtures.blank,
(config) => {
delete config.devtool;
configureEsbuildMinifyPlugin(config);
config.plugins!.push(
new webpack.SourceMapDevToolPlugin({}) as Webpack['SourceMapDevToolPlugin'],
);
},
webpack,
);
const { stats } = built;
expect(stats.hasWarnings()).toBe(false);
expect(stats.hasErrors()).toBe(false);
expect(
Object.keys(stats.compilation.assets).length,
).toBe(1);
const file = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(file).toContain('//# sourceMappingURL=data:application/');
});
test('minify w/ source-map option and source-map plugin external', async () => {
const built = await build(
fixtures.blank,
(config) => {
delete config.devtool;
configureEsbuildMinifyPlugin(config);
config.plugins!.push(
new webpack.SourceMapDevToolPlugin({
filename: 'index.js.map',
}) as Webpack['SourceMapDevToolPlugin'],
);
},
webpack,
);
const { stats } = built;
expect(stats.hasWarnings()).toBe(false);
expect(stats.hasErrors()).toBe(false);
expect(
Object.keys(stats.compilation.assets),
).toStrictEqual([
'index.js',
'index.js.map',
]);
const file = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(file).toContain('//# sourceMappingURL=index.js.map');
});
});
test('minify w/ query strings', async () => {
const built = await build(
{
'/src/index.js': 'import(/* webpackChunkName: "chunk" */"./chunk.js")',
'/src/chunk.js': '',
},
(config) => {
config.output!.filename = '[name].js?foo=bar';
config.output!.chunkFilename = '[name].js?foo=bar';
configureEsbuildMinifyPlugin(config);
},
webpack,
);
const { stats } = built;
expect(stats.hasWarnings()).toBe(false);
expect(stats.hasErrors()).toBe(false);
expect(
Object.keys(stats.compilation.assets).sort(),
).toStrictEqual([
'chunk.js?foo=bar',
'index.js?foo=bar',
]);
// The actual file name does not include the query string
const file = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(file).toMatch('?foo=bar');
});
describe('legalComments', () => {
test('minify w/ legalComments - default is inline', async () => {
const builtDefault = await build(
fixtures.legalComments,
(config) => {
configureEsbuildMinifyPlugin(config);
},
webpack,
);
const builtInline = await build(
fixtures.legalComments,
(config) => {
configureEsbuildMinifyPlugin(config, {
legalComments: 'inline',
});
},
webpack,
);
const fileInline = builtInline.fs.readFileSync('/dist/index.js', 'utf8');
const fileDefault = builtDefault.fs.readFileSync('/dist/index.js', 'utf8');
expect(fileDefault).toMatch('//! legal comment');
expect(fileDefault).toBe(fileInline);
});
test('minify w/ legalComments - eof', async () => {
const built = await build(
fixtures.legalComments,
(config) => {
configureEsbuildMinifyPlugin(config, {
legalComments: 'eof',
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const file = built.fs.readFileSync('/dist/index.js').toString();
expect(file.trim().endsWith('//! legal comment')).toBe(true);
});
test('minify w/ legalComments - none', async () => {
const built = await build(
fixtures.legalComments,
(config) => {
configureEsbuildMinifyPlugin(config, {
legalComments: 'none',
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const file = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(file).not.toMatch('//! legal comment');
});
test('minify w/ legalComments - external', async () => {
const built = await build(
fixtures.legalComments,
(config) => {
configureEsbuildMinifyPlugin(config, {
legalComments: 'external',
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
expect(Object.keys(built.stats.compilation.assets)).toStrictEqual([
'index.js',
'index.js.LEGAL.txt',
]);
const file = built.fs.readFileSync('/dist/index.js', 'utf8');
expect(file).not.toMatch('//! legal comment');
const extracted = built.fs.readFileSync('/dist/index.js.LEGAL.txt', 'utf8');
expect(extracted).toMatch('//! legal comment');
});
});
});
describe('implementation', () => {
test('error', async () => {
const runWithImplementation = async (implementation: EsbuildPluginOptions['implementation']) => {
await build(
fixtures.blank,
(config) => {
configureEsbuildMinifyPlugin(config, {
implementation,
});
},
webpack,
);
};
await expect(
// @ts-expect-error testing invalid type
runWithImplementation({}),
).rejects.toThrow(
'[EsbuildPlugin] implementation.transform must be an esbuild transform function. Received undefined',
);
await expect(
// @ts-expect-error testing invalid type
runWithImplementation({ transform: 123 }),
).rejects.toThrow(
'[EsbuildPlugin] implementation.transform must be an esbuild transform function. Received number',
);
});
test('customizable', async () => {
const code = 'export function foo() { return "CUSTOM_ESBUILD_IMPLEMENTATION"; }';
const built = await build(
fixtures.blank,
(config) => {
configureEsbuildMinifyPlugin(config, {
implementation: {
transform: async () => ({
code,
map: '',
warnings: [],
mangleCache: {},
legalComments: '',
}),
},
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
expect(
built.fs.readFileSync('/dist/index.js', 'utf8'),
).toBe(code);
});
test('customize with real esbuild', async () => {
const built = await build(
fixtures.minification,
(config) => {
configureEsbuildMinifyPlugin(config, {
implementation: esbuild,
});
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const exportedFunction = built.require('/dist/');
expect(exportedFunction('hello world')).toBe('hello world');
assertMinified(exportedFunction.toString());
});
});
describe('CSS', () => {
test('minify CSS asset', async () => {
const built = await build(
fixtures.css,
(config) => {
configureEsbuildMinifyPlugin(config, {
css: true,
});
configureMiniCssExtractPlugin(config);
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const file = built.fs.readFileSync('/dist/index.css').toString();
expect(file.trim()).not.toMatch(/\s{2,}/);
});
test('exclude', async () => {
const built = await build(
fixtures.css,
(config) => {
configureEsbuildMinifyPlugin(config, {
css: true,
exclude: /index\.css$/,
});
configureMiniCssExtractPlugin(config);
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const file = built.fs.readFileSync('/dist/index.css').toString();
expect(file.trim()).toMatch(/\s{2,}/);
});
test('minify w/ source-map', async () => {
const built = await build(
fixtures.css,
(config) => {
config.devtool = 'source-map';
configureEsbuildMinifyPlugin(config, {
css: true,
});
configureMiniCssExtractPlugin(config);
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const cssFile = built.fs.readFileSync('/dist/index.css').toString();
const css = cssFile.trim().split('\n');
expect(css[0]).not.toMatch(/\s{2,}/);
expect(css[2]).toMatch(/sourceMappingURL/);
const sourcemapFile = built.fs.readFileSync('/dist/index.css.map', 'utf8');
expect(sourcemapFile).toMatch(/styles\.css/);
});
});
test('supports Source without #sourceAndMap()', async () => {
const createSource = (content: string) => ({
source: () => content,
size: () => Buffer.byteLength(content),
}) as webpack5.sources.Source;
const built = await build(fixtures.blank, (config) => {
configureEsbuildMinifyPlugin(config);
config.plugins!.push({
apply: (compiler) => {
compiler.hooks.compilation.tap('test', (compilation) => {
compilation.hooks.processAssets.tap(
{ name: 'test' },
() => {
compilation.emitAsset(
'test.js',
createSource('console.log( 1 + 1)'),
);
},
);
});
},
});
}, webpack5);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
expect(Object.keys(built.stats.compilation.assets)).toStrictEqual([
'index.js',
'test.js',
]);
expect(
built.fs.readFileSync('/dist/test.js', 'utf8'),
).toBe('console.log(2);\n');
});
describe('minify targets', () => {
test('no iife for node', async () => {
const built = await build(
fixtures.getHelpers,
(config) => {
configureEsbuildMinifyPlugin(config, {
target: 'es2015',
});
config.target = webpackIs4 ? 'node' : ['node'];
delete config.output?.libraryTarget;
delete config.output?.libraryExport;
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const code = built.fs.readFileSync('/dist/index.js', 'utf8').toString();
expect(code.startsWith('var ')).toBe(true);
});
test('no iife for web with high target (no helpers are added)', async () => {
const built = await build(
fixtures.getHelpers,
(config) => {
configureEsbuildMinifyPlugin(config);
config.target = webpackIs4 ? 'web' : ['web'];
delete config.output?.libraryTarget;
delete config.output?.libraryExport;
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const code = built.fs.readFileSync('/dist/index.js', 'utf8').toString();
expect(code.startsWith('(()=>{var ')).toBe(false);
expect(countIife(code)).toBe(webpackIs4 ? 0 : 1);
});
test('iife for web & low target', async () => {
const built = await build(
fixtures.getHelpers,
(config) => {
configureEsbuildMinifyPlugin(config, {
target: 'es2015',
});
config.target = webpackIs4 ? 'web' : ['web'];
delete config.output?.libraryTarget;
delete config.output?.libraryExport;
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const code = built.fs.readFileSync('/dist/index.js', 'utf8').toString();
expect(code.startsWith('(()=>{var ')).toBe(true);
expect(code.endsWith('})();\n')).toBe(true);
expect(countIife(code)).toBe(webpackIs4 ? 1 : 2);
});
});
test('supports webpack-merge', async () => {
const built = await build(
fixtures.minification,
(config) => {
configureEsbuildMinifyPlugin(config);
const clonedConfig = merge({}, config);
config.optimization = clonedConfig.optimization;
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const exportedFunction = built.require('/dist/');
expect(exportedFunction('hello world')).toBe('hello world');
assertMinified(exportedFunction.toString());
});
// https://github.com/privatenumber/esbuild-loader/issues/356
test('can handle empty modules set', async () => {
await expect(build(
fixtures.blank,
(config) => {
config.entry = 'not-there.js';
configureEsbuildMinifyPlugin(config);
},
webpack,
)).resolves.toBeTruthy();
});
test('multiple plugins', async () => {
const built = await build(
fixtures.define,
(config) => {
configureEsbuildMinifyPlugin(config);
config.plugins?.push(
new EsbuildPlugin({
define: {
__TEST1__: '123',
},
}),
new EsbuildPlugin({
define: {
__TEST2__: '321',
},
}),
);
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const exportedFunction = built.require('/dist/');
expect(exportedFunction('hello world')).toStrictEqual([123, 321]);
});
test('warns when define is used with eval devtool', async () => {
const built = await build(
fixtures.minification,
(config) => {
config.devtool = 'eval-source-map';
config.plugins?.push(
new EsbuildPlugin({
define: {
__TEST__: '"value"',
},
}),
);
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(true);
const warnings = built.stats.toJson().warnings!;
expect(warnings.length).toBe(1);
// Webpack 4 returns string, Webpack 5 returns object with message property
const warningMessage = typeof warnings[0] === 'string' ? warnings[0] : warnings[0].message;
expect(warningMessage).toMatch('[EsbuildPlugin] The "define" option may not work as expected with eval-based devtools (current: "eval-source-map")');
expect(warningMessage).toMatch('Consider using the loader\'s "define" option instead');
});
test('no warning when define is used with non-eval devtool', async () => {
const built = await build(
fixtures.minification,
(config) => {
config.devtool = 'source-map';
config.plugins?.push(
new EsbuildPlugin({
define: {
__TEST__: '"value"',
},
}),
);
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
});
test('no warning when define is not used with eval devtool', async () => {
const built = await build(
fixtures.minification,
(config) => {
config.devtool = 'eval-source-map';
config.plugins?.push(new EsbuildPlugin());
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
});
test('no warning when define is empty with eval devtool', async () => {
const built = await build(
fixtures.minification,
(config) => {
config.devtool = 'eval-source-map';
config.plugins?.push(
new EsbuildPlugin({
define: {},
}),
);
},
webpack,
);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
});
});
};
================================================
FILE: tests/specs/tsconfig.ts
================================================
import path from 'path';
import { createRequire } from 'node:module';
import { describe, test, expect } from 'manten';
import { createFixture } from 'fs-fixture';
import { execa } from 'execa';
import { tsconfigJson } from '../utils.js';
const require = createRequire(import.meta.url);
const webpackCli = path.resolve('node_modules/webpack-cli/bin/cli.js');
const esbuildLoader = path.resolve('dist/index.cjs');
const detectStrictMode = '(function() { return !this; })()';
describe('tsconfig', () => {
describe('loader', () => {
test('auto-detects tsconfig and applies to all files regardless of include patterns', async () => {
await using fixture = await createFixture({
src: {
'index.ts': `module.exports = [
${detectStrictMode},
require("./lib.ts"),
require("./nested/also-strict.ts"),
];`,
'lib.ts': `module.exports = ${detectStrictMode}`,
nested: {
'also-strict.ts': `module.exports = ${detectStrictMode}`,
'tsconfig.json': tsconfigJson({
compilerOptions: {
strict: true,
},
}),
},
},
'webpack.config.js': `
module.exports = {
mode: 'production',
optimization: {
minimize: false,
},
resolveLoader: {
alias: {
'esbuild-loader': ${JSON.stringify(esbuildLoader)},
},
},
module: {
rules: [{
test: /\\.ts$/,
loader: 'esbuild-loader',
}],
},
entry: './src/index.ts',
output: {
libraryTarget: 'commonjs2',
},
};
`,
'tsconfig.json': tsconfigJson({
compilerOptions: {
strict: true,
},
include: [
'src/index.ts',
],
}),
});
await execa(webpackCli, {
cwd: fixture.path,
});
// All files get strict mode from their nearest tsconfig
// lib.ts is NOT in include pattern but still gets strict mode
expect(
require(path.join(fixture.path, 'dist/main.js')),
).toStrictEqual([true, true, true]);
});
test('handles resource with query string', async () => {
await using fixture = await createFixture({
src: {
'index.ts': `module.exports = [${detectStrictMode}, require("./lib.ts?some-query")];`,
'lib.ts': `module.exports = ${detectStrictMode}`,
},
'webpack.config.js': `
module.exports = {
mode: 'production',
optimization: {
minimize: false,
},
resolveLoader: {
alias: {
'esbuild-loader': ${JSON.stringify(esbuildLoader)},
},
},
module: {
rules: [{
test: /\\.ts$/,
loader: 'esbuild-loader',
}],
},
entry: './src/index.ts',
output: {
libraryTarget: 'commonjs2',
},
};
`,
'tsconfig.json': tsconfigJson({
compilerOptions: {
strict: true,
},
}),
});
await execa(webpackCli, {
cwd: fixture.path,
});
// Both files get strict mode even with query string in require
expect(
require(path.join(fixture.path, 'dist/main.js')),
).toStrictEqual([true, true]);
});
test('applies custom tsconfig to all files regardless of include patterns', async () => {
await using fixture = await createFixture({
src: {
'utils/lib.ts': `module.exports = ${detectStrictMode}`,
'app/index.ts': `module.exports = [${detectStrictMode}, require("../utils/lib.ts")];`,
},
'webpack.config.js': `
module.exports = {
mode: 'production',
optimization: {
minimize: false,
},
resolveLoader: {
alias: {
'esbuild-loader': ${JSON.stringify(esbuildLoader)},
},
},
module: {
rules: [{
test: /\\.ts$/,
loader: 'esbuild-loader',
options: {
tsconfig: './tsconfig.json',
}
}],
},
entry: './src/app/index.ts',
output: {
libraryTarget: 'commonjs2',
},
};
`,
'tsconfig.json': tsconfigJson({
compilerOptions: {
strict: true,
},
include: [
'src/app/**/*',
],
}),
});
const { stdout, exitCode } = await execa(webpackCli, {
cwd: fixture.path,
});
// Should NOT produce warnings even though lib.ts is not in include patterns
// TypeScript applies tsconfig to all imports
expect(stdout).not.toMatch('does not match its "include" patterns');
expect(exitCode).toBe(0);
// Both files should have strict mode applied
expect(
require(path.join(fixture.path, 'dist/main.js')),
).toStrictEqual([true, true]);
});
test('applies different tsconfig.json paths', async () => {
await using fixture = await createFixture({
src: {
'index.ts': 'export class C { foo = 100; }',
'index2.ts': 'export class C { foo = 100; }',
},
'webpack.config.js': `
module.exports = {
mode: 'production',
optimization: {
minimize: false,
},
resolveLoader: {
alias: {
'esbuild-loader': ${JSON.stringify(esbuildLoader)},
},
},
module: {
rules: [
{
test: /index\\.ts$/,
loader: 'esbuild-loader',
options: {
tsconfig: './tsconfig.custom1.json',
}
},
{
test: /index2\\.ts$/,
loader: 'esbuild-loader',
options: {
tsconfig: './tsconfig.custom2.json',
}
}
],
},
entry: {
index1: './src/index.ts',
index2: './src/index2.ts',
},
output: {
libraryTarget: 'commonjs2',
},
};
`,
'tsconfig.custom1.json': tsconfigJson({
compilerOptions: {
useDefineForClassFields: false,
},
}),
'tsconfig.custom2.json': tsconfigJson({
compilerOptions: {
useDefineForClassFields: true,
},
}),
});
await execa(webpackCli, {
cwd: fixture.path,
});
const code1 = await fixture.readFile('dist/index1.js', 'utf8');
expect(code1).toMatch('this.foo = 100;');
const code2 = await fixture.readFile('dist/index2.js', 'utf8');
expect(code2).toMatch('__publicField(this, "foo", 100);');
});
test('fails on invalid tsconfig.json', async () => {
await using fixture = await createFixture({
'tsconfig.json': tsconfigJson({
extends: 'unresolvable-dep',
}),
src: {
'index.ts': `
console.log('Hello, world!' as numer);
`,
},
'webpack.config.js': `
module.exports = {
mode: 'production',
optimization: {
minimize: false,
},
resolveLoader: {
alias: {
'esbuild-loader': ${JSON.stringify(esbuildLoader)},
},
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [
{
test: /.[tj]sx?$/,
loader: 'esbuild-loader',
options: {
target: 'es2015',
}
}
],
},
entry: {
index: './src/index.ts',
},
};
`,
});
const { stdout, exitCode } = await execa(webpackCli, {
cwd: fixture.path,
reject: false,
});
expect(stdout).toMatch('Error parsing tsconfig.json:\nFile \'unresolvable-dep\' not found.');
expect(exitCode).toBe(1);
});
test('ignores invalid tsconfig.json in JS dependencies', async () => {
await using fixture = await createFixture({
'node_modules/fake-lib': {
'package.json': JSON.stringify({
name: 'fake-lib',
}),
'tsconfig.json': tsconfigJson({
extends: 'unresolvable-dep',
}),
'index.js': 'export function testFn() { return "Hi!" }',
},
'src/index.ts': `
import { testFn } from "fake-lib";
testFn();
`,
'webpack.config.js': `
module.exports = {
mode: 'production',
optimization: {
minimize: false,
},
resolveLoader: {
alias: {
'esbuild-loader': ${JSON.stringify(esbuildLoader)},
},
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [
{
test: /.[tj]sx?$/,
loader: 'esbuild-loader',
options: {
target: 'es2015',
}
}
],
},
entry: {
index: './src/index.ts',
},
};
`,
});
const { stdout, exitCode } = await execa(webpackCli, {
cwd: fixture.path,
});
expect(stdout).not.toMatch('Error parsing tsconfig.json');
expect(exitCode).toBe(0);
});
test('warns on invalid tsconfig.json in TS dependencies', async () => {
await using fixture = await createFixture({
'node_modules/fake-lib': {
'package.json': JSON.stringify({
name: 'fake-lib',
}),
'tsconfig.json': tsconfigJson({
extends: 'unresolvable-dep',
}),
'index.ts': 'export function testFn(): string { return "Hi!" }',
},
'src/index.ts': `
import { testFn } from "fake-lib";
testFn();
`,
'webpack.config.js': `
module.exports = {
mode: 'production',
optimization: {
minimize: false,
},
resolveLoader: {
alias: {
'esbuild-loader': ${JSON.stringify(esbuildLoader)},
},
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [
{
test: /.[tj]sx?$/,
loader: 'esbuild-loader',
options: {
target: 'es2015',
}
}
],
},
entry: {
index: './src/index.ts',
},
};
`,
});
const { stdout, exitCode } = await execa(webpackCli, {
cwd: fixture.path,
});
expect(stdout).toMatch('Error parsing tsconfig.json:\nFile \'unresolvable-dep\' not found.');
// Warning so doesn't fail
expect(exitCode).toBe(0);
});
});
describe('plugin', () => {
/**
* Since the plugin applies on distribution assets, it should not apply
* any tsconfig settings.
*/
test('should not detect tsconfig.json and apply strict mode', async () => {
await using fixture = await createFixture({
src: {
'index.js': 'console.log(1)',
},
'webpack.config.js': `
const { EsbuildPlugin } = require(${JSON.stringify(esbuildLoader)});
module.exports = {
mode: 'production',
optimization: {
minimizer: [
new EsbuildPlugin(),
],
},
entry: './src/index.js',
};
`,
'tsconfig.json': tsconfigJson({
compilerOptions: {
strict: true,
},
}),
});
await execa(webpackCli, {
cwd: fixture.path,
});
const code = await fixture.readFile('dist/main.js', 'utf8');
expect(code).not.toMatch('use strict');
});
});
});
================================================
FILE: tests/specs/webpack5.ts
================================================
import { describe, test, expect } from 'manten';
import { build } from 'webpack-test-utils';
import webpack5 from 'webpack5';
import { configureEsbuildMinifyPlugin } from '../utils.js';
const { RawSource } = webpack5.sources;
describe('Webpack 5', () => {
test('Stats', async () => {
const built = await build({ '/src/index.js': '' }, (config) => {
configureEsbuildMinifyPlugin(config);
}, webpack5);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
expect(built.stats.toString().includes('[minimized]')).toBe(true);
});
test('Minifies new assets', async () => {
const built = await build({ '/src/index.js': '' }, (config) => {
configureEsbuildMinifyPlugin(config);
config.plugins!.push({
apply: (compiler) => {
compiler.hooks.compilation.tap('test', (compilation) => {
compilation.hooks.processAssets.tap(
{ name: 'test' },
() => {
compilation.emitAsset(
'test.js',
new RawSource('const value = 1;\n\nexport default value;'),
);
},
);
});
},
});
}, webpack5);
expect(built.stats.hasWarnings()).toBe(false);
expect(built.stats.hasErrors()).toBe(false);
const asset = built.stats.compilation.getAsset('test.js');
expect(asset!.info.minimized).toBe(true);
const file = built.fs.readFileSync('/dist/test.js', 'utf8');
expect(file).toBe('const e=1;export default 1;\n');
});
test('Doesnt minify minimized assets', async () => {
let sourceAndMapCalled = false;
await build({ '/src/index.js': '' }, (config) => {
configureEsbuildMinifyPlugin(config);
config.plugins!.push({
apply: (compiler) => {
compiler.hooks.compilation.tap('test', (compilation) => {
compilation.hooks.processAssets.tap(
{ name: 'test' },
() => {
const asset = new RawSource('');
// @ts-expect-error overwriting to make sure it's not called
asset.sourceAndMap = () => {
sourceAndMapCalled = true;
};
compilation.emitAsset(
'test.js',
asset,
{ minimized: true },
);
},
);
});
},
});
}, webpack5);
expect(sourceAndMapCalled).toBe(false);
});
});
================================================
FILE: tests/utils.ts
================================================
import path from 'path';
import type webpack4 from 'webpack';
import type webpack5 from 'webpack5';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import type { TsConfigJson } from 'get-tsconfig';
import { EsbuildPlugin, type EsbuildPluginOptions } from '#esbuild-loader';
const esbuildLoaderPath = path.resolve('./dist/index.cjs');
type Webpack4 = typeof webpack4;
type Webpack5 = typeof webpack5;
export type Webpack = Webpack4 & Webpack5;
export type WebpackConfiguration = webpack4.Configuration | webpack5.Configuration;
type RuleSetUseItem = webpack4.RuleSetUseItem & webpack5.RuleSetUseItem;
type RuleSetRule = webpack4.RuleSetRule & webpack5.RuleSetRule;
export const isWebpack4 = (
webpack: Webpack4 | Webpack5,
): webpack is Webpack4 => Boolean(webpack.version?.startsWith('4.'));
export const configureEsbuildLoader = (
config: WebpackConfiguration,
rulesConfig?: RuleSetRule,
) => {
config.resolveLoader!.alias = {
'esbuild-loader': esbuildLoaderPath,
};
config.module!.rules!.push({
test: /\.js$/,
loader: 'esbuild-loader',
...rulesConfig,
options: {
tsconfigRaw: undefined,
...(
typeof rulesConfig?.options === 'object'
? rulesConfig.options
: {}
),
},
});
};
export const configureEsbuildMinifyPlugin = (
config: WebpackConfiguration,
options?: EsbuildPluginOptions,
) => {
config.optimization = {
minimize: true,
minimizer: [
new EsbuildPlugin({
tsconfigRaw: undefined,
...options,
}),
],
};
};
export const configureCssLoader = (
config: WebpackConfiguration,
) => {
const cssRule = {
test: /\.css$/,
use: [
'css-loader',
] as RuleSetUseItem[],
};
config.module!.rules!.push(cssRule);
return cssRule;
};
export const configureMiniCssExtractPlugin = (
config: WebpackConfiguration,
) => {
const cssRule = configureCssLoader(config);
cssRule.use.unshift(MiniCssExtractPlugin.loader);
config.plugins!.push(
// @ts-expect-error Forcing it to Webpack 5
new MiniCssExtractPlugin(),
);
};
export const tsconfigJson = (
tsconfigObject: TsConfigJson,
) => JSON.stringify(tsconfigObject);
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "es2022",
"lib": [
"ESNext",
],
"moduleDetection": "force",
"module": "preserve",
"resolveJsonModule": true,
"allowJs": true,
"strict": true,
// "noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noEmit": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
},
}