├── .editorconfig ├── .env ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ └── feature-request.yml ├── logo.svg └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package.json ├── pnpm-lock.yaml ├── src ├── @types │ ├── webpack-module-filename-helpers.d.ts │ └── webpack.d.ts ├── index.d.ts ├── index.ts ├── loader.ts ├── plugin.ts └── types.ts ├── tests ├── fixtures.ts ├── index.ts ├── specs │ ├── loader.ts │ ├── plugin.ts │ ├── tsconfig.ts │ └── webpack5.ts └── utils.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # https://github.com/webpack/webpack/issues/14532 2 | NODE_OPTIONS=--openssl-legacy-provider 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug report 2 | description: Run into a bug? File a report and get the help you need! 3 | labels: [bug, pending triage] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | > 💁‍♂️ Keep in mind this is a collaborative effort. Please do your best to debug, communicate, and demonstrate the problem. 9 | 10 | ## 👀 Tell us about the bug 11 | A _clear and concise_ description of what the bug is. 12 | 13 | - type: textarea 14 | attributes: 15 | label: Problem 16 | description: Please refrain from describing anything other than the problem. 17 | placeholder: | 18 | What's the problem? 19 | Do you have an error stack trace? 20 | Do you have screenshots? 21 | validations: 22 | required: true 23 | 24 | - type: textarea 25 | attributes: 26 | label: Expected behavior 27 | placeholder: | 28 | What did you expect to happen? 29 | validations: 30 | required: true 31 | 32 | - type: markdown 33 | attributes: 34 | value: | 35 | ## 📋 Minimal reproduction 36 | > ⚠️ If a **minimal** reproduction is not provided, **the issue will be closed.** 37 | 38 | The minimal reproduction proves a bug exists in this project, allows others to debug it for you, and streamlines resolution. 39 | 40 |
41 | How do I create a minimal reproduction? 42 | 43 | 1. Delete all unnecessary files and data. Keep it under 10 files. 44 | 45 | Delete irrelevant files (e.g. `LICENSE`, `.npmrc`, `.github`). 46 | Do you have unnecessary dependencies, scripts, properties in `package.json`? 47 | 48 | 2. Delete all unnecessary code. Reduce the scope. 49 | 50 | Try to narrow the scope of the reproduction as much as possible. 51 | Is it a frontend or backend problem? Delete the other. 52 | Ideally, the code is reduced to a few lines of code in a single file. 53 | 54 | 3. Set the `start` script in `package.json` to the command that demonstrates the bug. 55 | 56 | 4. Verify the reproduction. 57 | 58 | Try running it yourself, and check: 59 | - Is the problem immediately reproducible? 60 | - Are dependencies properly declared? 61 | - Could I find more files or code that isn't necessary? 62 | 63 | 5. Upload the reproduction to [StackBlitz](https://stackblitz.com), or a new GitHub repository, so it can be opened in the browser. 64 |
65 | 66 | Starter template: [fork this template on StackBlitz](https://stackblitz.com/edit/node-guv65j?file=webpack.config.js&view=editor) (Delete everything unnecessary) 67 | 68 | The _smaller_ the reproduction, the _faster_ others can help you. 69 | 70 | - type: input 71 | attributes: 72 | label: Minimal reproduction URL 73 | placeholder: https://stackblitz.com/edit/... 74 | validations: 75 | required: true 76 | 77 | - type: markdown 78 | attributes: 79 | value: | 80 | > **🙋 Need help?** 81 | > 82 | > Get personalized help through my [_Priority Support_ service](https://github.com/sponsors/privatenumber). 83 | > From minimal reproduction creation to debugging, I'm happy to assist you! 84 | 85 | - type: markdown 86 | attributes: 87 | value: "## 🌍 Environment" 88 | 89 | - type: input 90 | attributes: 91 | label: Version 92 | placeholder: v0.0.0 93 | validations: 94 | required: true 95 | 96 | - type: input 97 | attributes: 98 | label: Node.js version 99 | placeholder: v0.0.0 100 | validations: 101 | required: true 102 | 103 | - type: dropdown 104 | id: package-manager 105 | attributes: 106 | label: Package manager 107 | options: 108 | - npm 109 | - yarn 110 | - pnpm 111 | - bun 112 | - N/A 113 | validations: 114 | required: true 115 | 116 | - type: dropdown 117 | attributes: 118 | label: Operating system 119 | options: 120 | - macOS 121 | - Windows 122 | - Linux 123 | validations: 124 | required: true 125 | 126 | - type: markdown 127 | attributes: 128 | value: | 129 | ## 🛠️ Contribute 130 | It would be amazing if you can contribute to the project! This project is open source, free to use, and maintained by volunteers. This could be a great opportunity to give back and improve the project for everyone, including yourself. 131 | 132 | - type: checkboxes 133 | attributes: 134 | label: Contributions 135 | options: 136 | - label: I plan to open a pull request for this issue 137 | - label: I plan to make a financial contribution to this project 138 | 139 | - type: markdown 140 | attributes: 141 | value: | 142 | ## 🚀 Need immediate attention? 143 | Escalate this issue by becoming a [_Priority Patron_ sponsor](https://github.com/sponsors/privatenumber)! As a _Priority Patron_, your concern will receive prompt attention, ensuring faster and more efficient resolution. 144 | 145 | [👉 Become a _Priority Patron_ now!](https://github.com/sponsors/privatenumber) 146 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 💬 Help / Questions / Discussions 4 | url: https://github.com/privatenumber/esbuild-loader/discussions 5 | about: Use GitHub Discussions for anything else 6 | 7 | - name: 🚀 Priority Support 8 | url: https://github.com/sponsors/privatenumber/ 9 | about: Need help? Get prioritized help for all your questions and issues 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: 🌟 Feature request 2 | description: Have a great idea for this project? Tell us more! 3 | labels: [enhancement, pending triage] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | > 💁‍♂️ Please remember others are volunteering to help you for free, and put in your best effort to follow this form. 9 | 10 | ## 👀 Tell us about your idea 11 | 12 | - type: textarea 13 | attributes: 14 | label: Feature request 15 | description: A clear and concise description of the feature. 16 | placeholder: | 17 | I would love to be able to... 18 | validations: 19 | required: true 20 | 21 | - type: textarea 22 | attributes: 23 | label: Motivations 24 | description: | 25 | Describe the problem you’re tackling with this feature request. 26 | placeholder: | 27 | How did you come across this idea? 28 | validations: 29 | required: true 30 | 31 | - type: textarea 32 | attributes: 33 | label: Alternatives 34 | description: | 35 | Have you considered alternative solutions? Is there a workaround? 36 | placeholder: | 37 | Do you have alternative proposals? 38 | 39 | Are there workarounds? 40 | 41 | - type: textarea 42 | attributes: 43 | label: Additional context 44 | description: | 45 | Anything else to share? Screenshots? Links? 46 | 47 | - type: markdown 48 | attributes: 49 | value: | 50 | > **🙋 Experiencing a challenging problem and need expert assistance?** 51 | > 52 | > Get personalized help through my [_Priority Support_ service](https://github.com/sponsors/privatenumber). From debugging to implementation, I'm here to assist you! 53 | 54 | - type: markdown 55 | attributes: 56 | value: | 57 | ## 🛠️ Contribute 58 | It would be amazing if you can contribute to the project! This project is open source, free to use, and maintained by volunteers. This could be a great opportunity to give back and improve the project for everyone, including yourself. 59 | 60 | - type: checkboxes 61 | attributes: 62 | label: Contributions 63 | options: 64 | - label: I plan to open a pull request for this issue 65 | - label: I plan to make a financial contribution to this project 66 | 67 | - type: markdown 68 | attributes: 69 | value: | 70 | ## 🚀 Need immediate attention? 71 | Escalate this issue by becoming a [_Priority Patron_ sponsor](https://github.com/sponsors/privatenumber)! As a _Priority Patron_, your concern will receive prompt attention, ensuring faster and more efficient resolution. 72 | 73 | [👉 Become a _Priority Patron_ now!](https://github.com/sponsors/privatenumber) 74 | -------------------------------------------------------------------------------- /.github/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [master, develop] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | if: ( 14 | github.repository_owner == 'pvtnbr' && github.ref_name =='develop' 15 | ) || ( 16 | github.repository_owner == 'privatenumber' && github.ref_name =='master' 17 | ) 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 10 20 | 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | with: 25 | token: ${{ secrets.GH_TOKEN }} 26 | 27 | - name: Use Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version-file: .nvmrc 31 | 32 | - name: Setup pnpm 33 | uses: pnpm/action-setup@v3 34 | with: 35 | run_install: true 36 | 37 | - name: Prerelease to GitHub 38 | if: github.repository_owner == 'pvtnbr' 39 | run: | 40 | git remote add public https://github.com/$(echo $GITHUB_REPOSITORY | sed "s/^pvtnbr/privatenumber/") 41 | git fetch public master 'refs/tags/*:refs/tags/*' 42 | git push --force --tags origin refs/remotes/public/master:refs/heads/master 43 | 44 | jq ' 45 | .publishConfig.registry = "https://npm.pkg.github.com" 46 | | .name = ("@" + env.GITHUB_REPOSITORY_OWNER + "/" + .name) 47 | | .repository = env.GITHUB_REPOSITORY 48 | | .release.branches = [ 49 | "master", 50 | { name: "develop", prerelease: "rc", channel: "latest" } 51 | ] 52 | ' package.json > _package.json 53 | mv _package.json package.json 54 | 55 | - name: Release 56 | env: 57 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 58 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 59 | run: pnpm dlx semantic-release 60 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: [master, develop] 5 | pull_request: 6 | jobs: 7 | test: 8 | name: Test 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 10 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Use Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version-file: .nvmrc 20 | 21 | - name: Setup pnpm 22 | uses: pnpm/action-setup@v3 23 | with: 24 | run_install: true 25 | 26 | - name: Lint 27 | run: pnpm lint 28 | 29 | - name: Type check 30 | run: pnpm type-check 31 | 32 | - name: Build 33 | run: pnpm build 34 | 35 | - name: Test 36 | run: pnpm test 37 | 38 | - name: Test Node.js v16 39 | run: pnpm --use-node-version=16.19.0 tsx tests 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac OS X 2 | .DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | 9 | # Dependency directories 10 | node_modules/ 11 | 12 | # Optional npm cache directory 13 | .npm 14 | 15 | # Optional REPL history 16 | .node_repl_history 17 | 18 | # Output of 'npm pack' 19 | *.tgz 20 | 21 | # Distribution files 22 | dist 23 | 24 | # Cache 25 | .eslintcache 26 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shell-emulator=true 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.14.0 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Hiroki Osame 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | esbuild-loader 5 |

6 | 7 |

8 | 9 | Speed up your Webpack build with [esbuild](https://github.com/evanw/esbuild)! 🔥 10 | 11 | [_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/). 12 | 13 | [_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)! 14 | 15 | > [!TIP] 16 | > **Are you using TypeScript with Node.js?** 17 | > 18 | > Supercharge your Node.js with TypeScript support using _tsx_! 19 | > 20 | > _tsx_ is a simple, lightweight, and blazing fast alternative to ts-node. 21 | > 22 | > [→ Learn more about _tsx_](https://github.com/privatenumber/tsx) 23 | 24 |
25 | 26 |

27 | 28 | 29 |

30 |

Already a sponsor? Join the discussion in the Development repo!

31 | 32 | ## 🚀 Install 33 | 34 | ```bash 35 | npm i -D esbuild-loader 36 | ``` 37 | 38 | ## 🚦 Quick Setup 39 | 40 | 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`). 41 | 42 | Here's an example of how to set it up in your `webpack.config.js`: 43 | 44 | ```diff 45 | module.exports = { 46 | module: { 47 | rules: [ 48 | - // Transpile JavaScript 49 | - { 50 | - test: /\.js$/, 51 | - use: 'babel-loader' 52 | - }, 53 | - 54 | - // Compile TypeScript 55 | - { 56 | - test: /\.tsx?$/, 57 | - use: 'ts-loader' 58 | - }, 59 | + // Use esbuild to compile JavaScript & TypeScript 60 | + { 61 | + // Match `.js`, `.jsx`, `.ts` or `.tsx` files 62 | + test: /\.[jt]sx?$/, 63 | + loader: 'esbuild-loader', 64 | + options: { 65 | + // JavaScript version to compile to 66 | + target: 'es2015' 67 | + } 68 | + }, 69 | 70 | // Other rules... 71 | ], 72 | }, 73 | } 74 | ``` 75 | 76 | In this setup, esbuild will automatically determine how to handle each file based on its extension: 77 | - `.js` files will be treated as JS (no JSX allowed) 78 | - `.jsx` as JSX 79 | - `.ts` as TS (no TSX allowed) 80 | - `.tsx` as TSX 81 | 82 | 83 | 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): 84 | 85 | ```diff 86 | { 87 | test: /\.js$/, 88 | loader: 'esbuild-loader', 89 | options: { 90 | + // Treat `.js` files as `.jsx` files 91 | + loader: 'jsx', 92 | 93 | // JavaScript version to transpile to 94 | target: 'es2015' 95 | } 96 | } 97 | ``` 98 | 99 | 100 | ## Loader 101 | 102 | ### JavaScript 103 | 104 | `esbuild-loader` can be used in-place of `babel-loader` to transpile new JavaScript syntax into code compatible with older JavaScript engines. 105 | 106 | While this ensures your code can run smoothly across various environments, note that it can bloat your output code (like Babel). 107 | 108 | The default target is `esnext`, which means it doesn't perform any transpilations. 109 | 110 | To specify a target JavaScript engine that only supports ES2015, use the following configuration in your `webpack.config.js`: 111 | 112 | ```diff 113 | { 114 | test: /\.jsx?$/, 115 | loader: 'esbuild-loader', 116 | options: { 117 | + target: 'es2015', 118 | }, 119 | } 120 | ``` 121 | 122 | For a detailed list of supported transpilations and versions, refer to [the esbuild documentation](https://esbuild.github.io/content-types/#javascript). 123 | 124 | ### TypeScript 125 | 126 | `esbuild-loader` can be used in-place of `ts-loader` to compile TypeScript. 127 | 128 | ```json5 129 | { 130 | // `.ts` or `.tsx` files 131 | test: /\.tsx?$/, 132 | loader: 'esbuild-loader', 133 | } 134 | ``` 135 | 136 | 137 | > [!IMPORTANT] 138 | > 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. 139 | > 140 | > [→ Read more](https://esbuild.github.io/content-types/#ts-vs-tsx) 141 | 142 | #### `tsconfig.json` 143 | If you have a `tsconfig.json` file in your project, `esbuild-loader` will automatically load it. 144 | 145 | If it's under a custom name, you can pass in the path via `tsconfig` option: 146 | ```diff 147 | { 148 | test: /\.tsx?$/, 149 | loader: 'esbuild-loader', 150 | options: { 151 | + tsconfig: './tsconfig.custom.json', 152 | }, 153 | }, 154 | ``` 155 | 156 | > 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. 157 | 158 | The `tsconfigRaw` option can be used to pass in a raw `tsconfig` object, but it will not resolve the `extends` property. 159 | 160 | 161 | ##### Caveats 162 | - esbuild only supports a subset of `tsconfig` options [(see `TransformOptions` interface)](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L159-L165). 163 | 164 | - Enable [`isolatedModules`](https://www.typescriptlang.org/tsconfig#isolatedModules) to avoid mis-compilation with features like re-exporting types. 165 | 166 | - Enable [`esModuleInterop`](https://www.typescriptlang.org/tsconfig/#esModuleInterop) to make TypeScript's type system compatible with ESM imports. 167 | 168 | - Features that require type interpretation, such as `emitDecoratorMetadata` and declaration, are not supported. 169 | 170 | [→ Read more about TypeScript Caveats](https://esbuild.github.io/content-types/#typescript-caveats) 171 | 172 | #### `tsconfig.json` Paths 173 | 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). 174 | 175 | Since `esbuild-loader` only transforms code, it cannot aid Webpack with resolving paths. 176 | 177 | 178 | #### Type-checking 179 | 180 | 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. 181 | 182 | Consider these type-checking alternatives: 183 | - 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 184 | - Running `tsc --noEmit` to type check 185 | - 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) 186 | 187 | ## EsbuildPlugin 188 | 189 | ### Minification 190 | 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). 191 | 192 | In `webpack.config.js`: 193 | 194 | ```diff 195 | + const { EsbuildPlugin } = require('esbuild-loader') 196 | 197 | module.exports = { 198 | ..., 199 | 200 | + optimization: { 201 | + minimizer: [ 202 | + new EsbuildPlugin({ 203 | + target: 'es2015' // Syntax to transpile to (see options below for possible values) 204 | + }) 205 | + ] 206 | + }, 207 | } 208 | ``` 209 | 210 | > [!TIP] 211 | > Utilizing the `target` option allows for the use of newer JavaScript syntax, enhancing minification effectiveness. 212 | 213 | ### Defining constants 214 | 215 | 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`. 216 | 217 | In `webpack.config.js`: 218 | 219 | ```diff 220 | - const { DefinePlugin } = require('webpack') 221 | + const { EsbuildPlugin } = require('esbuild-loader') 222 | 223 | module.exports = { 224 | // ..., 225 | 226 | plugins:[ 227 | - new DefinePlugin({ 228 | - 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 229 | - }) 230 | + new EsbuildPlugin({ 231 | + define: { 232 | + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 233 | + }, 234 | + }), 235 | ] 236 | } 237 | ``` 238 | 239 | ### Transpilation 240 | 241 | 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. 242 | 243 | 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. 244 | 245 | To utilize esbuild for transpilation, simply set the `target` option on the plugin to specify which syntax support you want. 246 | 247 | 248 | ## CSS Minification 249 | 250 | 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). 251 | 252 | ### CSS assets 253 | 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`. 254 | 255 | Assuming the CSS is extracted using something like [MiniCssExtractPlugin](https://github.com/webpack-contrib/mini-css-extract-plugin), in `webpack.config.js`: 256 | 257 | ```diff 258 | const { EsbuildPlugin } = require('esbuild-loader') 259 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 260 | 261 | module.exports = { 262 | // ..., 263 | 264 | optimization: { 265 | minimizer: [ 266 | new EsbuildPlugin({ 267 | target: 'es2015', 268 | + css: true // Apply minification to CSS assets 269 | }) 270 | ] 271 | }, 272 | 273 | module: { 274 | rules: [ 275 | { 276 | test: /\.css$/i, 277 | use: [ 278 | MiniCssExtractPlugin.loader, 279 | 'css-loader' 280 | ] 281 | } 282 | ], 283 | }, 284 | 285 | plugins: [ 286 | new MiniCssExtractPlugin() 287 | ] 288 | } 289 | ``` 290 | 291 | 292 | ### CSS in JS 293 | 294 | 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. 295 | 296 | 297 | In `webpack.config.js`: 298 | 299 | ```diff 300 | module.exports = { 301 | // ..., 302 | 303 | module: { 304 | rules: [ 305 | { 306 | test: /\.css$/i, 307 | use: [ 308 | 'style-loader', 309 | 'css-loader', 310 | + { 311 | + loader: 'esbuild-loader', 312 | + options: { 313 | + minify: true, 314 | + }, 315 | + }, 316 | ], 317 | }, 318 | ], 319 | }, 320 | } 321 | ``` 322 | 323 | ## Bring your own esbuild (Advanced) 324 | 325 | 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. 326 | 327 | 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). 328 | 329 | > [!WARNING] 330 | > ⚠esbuild is not stable yet and can have dramatic differences across releases. Using a different version of esbuild is not guaranteed to work. 331 | 332 | 333 | ```diff 334 | + const esbuild = require('esbuild') 335 | 336 | module.exports = { 337 | // ..., 338 | 339 | module: { 340 | rules: [ 341 | { 342 | test: ..., 343 | loader: 'esbuild-loader', 344 | options: { 345 | // ..., 346 | + implementation: esbuild, 347 | }, 348 | }, 349 | ], 350 | }, 351 | } 352 | ``` 353 | 354 | ## Setup examples 355 | 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: 356 | 357 | [→ esbuild-loader examples](https://github.com/privatenumber/esbuild-loader-examples) 358 | 359 | ## ⚙️ Options 360 | 361 | ### Loader 362 | The loader supports [all Transform options from esbuild](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L158-L172). 363 | 364 | Note: 365 | - Source-maps are automatically configured for you via [`devtool`](https://webpack.js.org/configuration/devtool/). `sourcemap`/`sourcefile` options are ignored. 366 | - 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. 367 | 368 | 369 | Here are some common configurations and custom options: 370 | 371 | #### tsconfig 372 | 373 | Type: `string` 374 | 375 | Pass in the file path to a **custom** tsconfig file. If the file name is `tsconfig.json`, it will automatically detect it. 376 | 377 | #### target 378 | Type: `string | Array` 379 | 380 | Default: `'es2015'` 381 | 382 | The target environment (e.g. `es2016`, `chrome80`, `esnext`). 383 | 384 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#target). 385 | 386 | #### loader 387 | Type: `'js' | 'jsx' | 'ts' | 'tsx' | 'css' | 'json' | 'text' | 'base64' | 'file' | 'dataurl' | 'binary' | 'default'` 388 | 389 | Default: `'default'` 390 | 391 | 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). 392 | 393 | By default, it automatically detects the loader based on the file extension. 394 | 395 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#loader). 396 | 397 | #### jsxFactory 398 | Type: `string` 399 | 400 | Default: `React.createElement` 401 | 402 | Customize the JSX factory function name to use. 403 | 404 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#jsx-factory). 405 | 406 | #### jsxFragment 407 | Type: `string` 408 | 409 | Default: `React.Fragment` 410 | 411 | Customize the JSX fragment function name to use. 412 | 413 | 414 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#jsx-fragment). 415 | 416 | #### implementation 417 | Type: `{ transform: Function }` 418 | 419 | _Custom esbuild-loader option._ 420 | 421 | Use it to pass in a [different esbuild version](#bring-your-own-esbuild-advanced). 422 | 423 | ### EsbuildPlugin 424 | 425 | The loader supports [all Transform options from esbuild](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L158-L172). 426 | 427 | #### target 428 | Type: `string | Array` 429 | 430 | Default: `'esnext'` 431 | 432 | Target environment (e.g. `'es2016'`, `['chrome80', 'esnext']`) 433 | 434 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#target). 435 | 436 | Here are some common configurations and custom options: 437 | 438 | #### format 439 | Type: `'iife' | 'cjs' | 'esm'` 440 | 441 | Default: 442 | - `iife` if both of these conditions are met: 443 | - Webpack's [`target`](https://webpack.js.org/configuration/target/) is set to `web` 444 | - esbuild's [`target`](#target-1) is not `esnext` 445 | - `undefined` (no format conversion) otherwise 446 | 447 | 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. 448 | 449 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#format). 450 | 451 | #### minify 452 | Type: `boolean` 453 | 454 | Default: `true` 455 | 456 | Enable JS minification. Enables all `minify*` flags below. 457 | 458 | To have nuanced control over minification, disable this and enable the specific minification you want below. 459 | 460 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#minify). 461 | 462 | #### minifyWhitespace 463 | Type: `boolean` 464 | 465 | Minify JS by removing whitespace. 466 | 467 | #### minifyIdentifiers 468 | Type: `boolean` 469 | 470 | Minify JS by shortening identifiers. 471 | 472 | #### minifySyntax 473 | Type: `boolean` 474 | 475 | Minify JS using equivalent but shorter syntax. 476 | 477 | #### legalComments 478 | Type: `'none' | 'inline' | 'eof' | 'external'` 479 | 480 | Default: `'inline'` 481 | 482 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#legal-comments). 483 | 484 | #### css 485 | Type: `boolean` 486 | 487 | Default: `false` 488 | 489 | Whether to minify CSS files. 490 | 491 | #### include 492 | Type: `string | RegExp | Array` 493 | 494 | To only apply the plugin to certain assets, pass in filters include 495 | 496 | #### exclude 497 | Type: `string | RegExp | Array` 498 | 499 | To prevent the plugin from applying to certain assets, pass in filters to exclude 500 | 501 | #### implementation 502 | Type: `{ transform: Function }` 503 | 504 | Use it to pass in a [different esbuild version](#bring-your-own-esbuild-advanced). 505 | 506 | ## 💡 Support 507 | 508 | For personalized assistance, take advantage of my [_Priority Support_ service](https://github.com/sponsors/privatenumber). 509 | 510 | Whether it's about Webpack configuration, esbuild, or TypeScript, I'm here to guide you every step of the way! 511 | 512 | ## 🙋‍♀️ FAQ 513 | 514 | ### Is it possible to use esbuild plugins? 515 | 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: 516 | 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. 517 | 518 | 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. 519 | 520 | ### Is it possible to use esbuild's [inject](https://esbuild.github.io/api/#inject) option? 521 | 522 | No. The `inject` option is only available in the build API. And esbuild-loader uses the transform API. 523 | 524 | However, you can use the Webpack equivalent [ProvidePlugin](https://webpack.js.org/plugins/provide-plugin/) instead. 525 | 526 | 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. 527 | 528 | ### Is it possible to use Babel plugins? 529 | No. If you really need them, consider porting them over to a Webpack loader. 530 | 531 | And please don't chain `babel-loader` and `esbuild-loader`. The speed gains come from replacing `babel-loader`. 532 | 533 | ### Why am I not getting a [100x speed improvement](https://esbuild.github.io/faq/#benchmark-details) as advertised? 534 | Running esbuild as a standalone bundler vs esbuild-loader + Webpack are completely different: 535 | - 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). 536 | - 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. 537 | 538 | 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. 539 | 540 | 541 | ## 💞 Related projects 542 | 543 | #### [tsx](https://github.com/esbuild-kit/tsx) 544 | Node.js enhanced with esbuild to run TypeScript and ESM. 545 | 546 | #### [instant-mocha](https://github.com/privatenumber/instant-mocha) 547 | Webpack-integrated Mocha test-runner with Webpack 5 support. 548 | 549 | #### [webpack-localize-assets-plugin](https://github.com/privatenumber/webpack-localize-assets-plugin) 550 | Localize/i18nalize your Webpack build. Optimized for multiple locales! 551 | 552 | ## Sponsors 553 | 554 |

555 | 556 | 557 | 558 |

559 | 560 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esbuild-loader", 3 | "version": "0.0.0-semantic-release", 4 | "description": "⚡️ Speed up your Webpack build with esbuild", 5 | "keywords": [ 6 | "esbuild", 7 | "webpack", 8 | "loader", 9 | "typescript", 10 | "esnext" 11 | ], 12 | "license": "MIT", 13 | "repository": "privatenumber/esbuild-loader", 14 | "funding": "https://github.com/privatenumber/esbuild-loader?sponsor=1", 15 | "author": { 16 | "name": "Hiroki Osame", 17 | "email": "hiroki.osame@gmail.com" 18 | }, 19 | "files": [ 20 | "dist" 21 | ], 22 | "type": "module", 23 | "main": "./dist/index.cjs", 24 | "types": "./dist/index.d.cts", 25 | "exports": { 26 | ".": { 27 | "types": "./dist/index.d.cts", 28 | "default": "./dist/index.cjs" 29 | }, 30 | "./package.json": "./package.json" 31 | }, 32 | "imports": { 33 | "#esbuild-loader": { 34 | "types": "./src/index.d.ts", 35 | "development": "./src/index.ts", 36 | "default": "./dist/index.cjs" 37 | } 38 | }, 39 | "scripts": { 40 | "build": "pkgroll --target=node16.19.0", 41 | "test": "tsx --env-file=.env tests", 42 | "dev": "tsx watch --env-file=.env --conditions=development tests", 43 | "lint": "lintroll --cache .", 44 | "type-check": "tsc --noEmit", 45 | "prepack": "pnpm build && clean-pkg-json" 46 | }, 47 | "peerDependencies": { 48 | "webpack": "^4.40.0 || ^5.0.0" 49 | }, 50 | "dependencies": { 51 | "esbuild": "^0.25.0", 52 | "get-tsconfig": "^4.7.0", 53 | "loader-utils": "^2.0.4", 54 | "webpack-sources": "^1.4.3" 55 | }, 56 | "devDependencies": { 57 | "@types/loader-utils": "^2.0.3", 58 | "@types/mini-css-extract-plugin": "2.4.0", 59 | "@types/node": "^18.13.0", 60 | "@types/webpack": "^4.41.33", 61 | "@types/webpack-sources": "^0.1.9", 62 | "clean-pkg-json": "^1.2.0", 63 | "css-loader": "^5.2.7", 64 | "execa": "^8.0.1", 65 | "fs-fixture": "^2.4.0", 66 | "lintroll": "^1.6.1", 67 | "manten": "^1.3.0", 68 | "memfs": "^4.9.3", 69 | "mini-css-extract-plugin": "^1.6.2", 70 | "pkgroll": "^2.1.1", 71 | "tsx": "^4.15.6", 72 | "typescript": "^5.4.5", 73 | "webpack": "^4.44.2", 74 | "webpack-cli": "^4.10.0", 75 | "webpack-merge": "^5.9.0", 76 | "webpack-test-utils": "^2.1.0", 77 | "webpack5": "npm:webpack@^5.0.0" 78 | }, 79 | "pnpm": { 80 | "overrides": { 81 | "fsevents@1": "^2.0.0" 82 | } 83 | }, 84 | "packageManager": "pnpm@9.2.0" 85 | } 86 | -------------------------------------------------------------------------------- /src/@types/webpack-module-filename-helpers.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'webpack/lib/ModuleFilenameHelpers.js' { 2 | type Filter = string | RegExp; 3 | type FilterObject = { 4 | test?: Filter | Filter[]; 5 | include?: Filter | Filter[]; 6 | exclude?: Filter | Filter[]; 7 | }; 8 | 9 | export const matchObject: (filterObject: FilterObject, stringToCheck: string) => boolean; 10 | } 11 | -------------------------------------------------------------------------------- /src/@types/webpack.d.ts: -------------------------------------------------------------------------------- 1 | import 'webpack'; 2 | import type { LoaderContext as Webpack5LoaderContext } from 'webpack5'; 3 | 4 | declare module 'webpack' { 5 | 6 | namespace compilation { 7 | interface Compilation { 8 | getAssets(): Asset[]; 9 | emitAsset( 10 | file: string, 11 | source: Source, 12 | assetInfo?: AssetInfo, 13 | ): void; 14 | } 15 | } 16 | 17 | namespace loader { 18 | interface LoaderContext { 19 | getOptions: Webpack5LoaderContext['getOptions']; 20 | } 21 | } 22 | 23 | interface AssetInfo { 24 | minimized?: boolean; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { EsbuildPluginOptions } from './types.js'; 2 | 3 | export class EsbuildPlugin { 4 | constructor(options?: EsbuildPluginOptions); 5 | 6 | apply(): void; 7 | } 8 | 9 | export * from './types.js'; 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import esbuildLoader from './loader.js'; 2 | import EsbuildPlugin from './plugin.js'; 3 | 4 | export default esbuildLoader; 5 | export { EsbuildPlugin }; 6 | -------------------------------------------------------------------------------- /src/loader.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { 3 | transform as defaultEsbuildTransform, 4 | type TransformOptions, 5 | } from 'esbuild'; 6 | import { getOptions } from 'loader-utils'; 7 | import webpack from 'webpack'; 8 | import { 9 | getTsconfig, 10 | parseTsconfig, 11 | createFilesMatcher, 12 | type TsConfigResult, 13 | } from 'get-tsconfig'; 14 | import type { LoaderOptions } from './types.js'; 15 | 16 | const tsconfigCache = new Map(); 17 | 18 | const tsExtensionsPattern = /\.(?:[cm]?ts|[tj]sx)$/; 19 | 20 | async function ESBuildLoader( 21 | this: webpack.loader.LoaderContext, 22 | source: string, 23 | ): Promise { 24 | const done = this.async()!; 25 | const options: LoaderOptions = typeof this.getOptions === 'function' ? this.getOptions() : getOptions(this); 26 | const { 27 | implementation, 28 | tsconfig: tsconfigPath, 29 | ...esbuildTransformOptions 30 | } = options; 31 | 32 | if (implementation && typeof implementation.transform !== 'function') { 33 | done( 34 | new TypeError( 35 | `esbuild-loader: options.implementation.transform must be an ESBuild transform function. Received ${typeof implementation.transform}`, 36 | ), 37 | ); 38 | return; 39 | } 40 | const transform = implementation?.transform ?? defaultEsbuildTransform; 41 | 42 | const { resourcePath } = this; 43 | const transformOptions = { 44 | ...esbuildTransformOptions, 45 | target: options.target ?? 'es2015', 46 | loader: options.loader ?? 'default', 47 | sourcemap: this.sourceMap, 48 | sourcefile: resourcePath, 49 | }; 50 | 51 | const isDependency = resourcePath.includes(`${path.sep}node_modules${path.sep}`); 52 | if ( 53 | !('tsconfigRaw' in transformOptions) 54 | 55 | // If file is local project, always try to apply tsconfig.json (e.g. allowJs) 56 | // If file is dependency, only apply tsconfig.json if .ts 57 | && (!isDependency || tsExtensionsPattern.test(resourcePath)) 58 | ) { 59 | /** 60 | * If a tsconfig.json path is specified, force apply it 61 | * Same way a provided tsconfigRaw is applied regardless 62 | * of whether it actually matches 63 | * 64 | * However in this case, we also warn if it doesn't match 65 | */ 66 | if (!isDependency && tsconfigPath) { 67 | const tsconfigFullPath = path.resolve(tsconfigPath); 68 | const cacheKey = `esbuild-loader:${tsconfigFullPath}`; 69 | let tsconfig = tsconfigCache.get(cacheKey); 70 | if (!tsconfig) { 71 | tsconfig = { 72 | config: parseTsconfig(tsconfigFullPath, tsconfigCache), 73 | path: tsconfigFullPath, 74 | }; 75 | tsconfigCache.set(cacheKey, tsconfig); 76 | } 77 | 78 | const filesMatcher = createFilesMatcher(tsconfig); 79 | const matches = filesMatcher(resourcePath); 80 | 81 | if (!matches) { 82 | this.emitWarning( 83 | new Error(`esbuild-loader] The specified tsconfig at "${tsconfigFullPath}" was applied to the file "${resourcePath}" but does not match its "include" patterns`), 84 | ); 85 | } 86 | 87 | transformOptions.tsconfigRaw = tsconfig.config as TransformOptions['tsconfigRaw']; 88 | } else { 89 | /* Detect tsconfig file */ 90 | 91 | let tsconfig; 92 | 93 | try { 94 | // Webpack shouldn't be loading the same path multiple times so doesn't need to be cached 95 | tsconfig = getTsconfig(resourcePath, 'tsconfig.json', tsconfigCache); 96 | } catch (error) { 97 | if (error instanceof Error) { 98 | const tsconfigError = new Error(`[esbuild-loader] Error parsing tsconfig.json:\n${error.message}`); 99 | if (isDependency) { 100 | this.emitWarning(tsconfigError); 101 | } else { 102 | return done(tsconfigError); 103 | } 104 | } 105 | } 106 | 107 | if (tsconfig) { 108 | const fileMatcher = createFilesMatcher(tsconfig); 109 | transformOptions.tsconfigRaw = fileMatcher(resourcePath) as TransformOptions['tsconfigRaw']; 110 | } 111 | } 112 | } 113 | 114 | /** 115 | * Enable dynamic import by default to support code splitting in Webpack 116 | */ 117 | transformOptions.supported = { 118 | 'dynamic-import': true, 119 | ...transformOptions.supported, 120 | }; 121 | 122 | try { 123 | const { code, map } = await transform(source, transformOptions); 124 | done(null, code, map && JSON.parse(map)); 125 | } catch (error: unknown) { 126 | done(error as Error); 127 | } 128 | } 129 | 130 | export default ESBuildLoader; 131 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { transform as defaultEsbuildTransform } from 'esbuild'; 2 | import { 3 | RawSource as WP4RawSource, 4 | SourceMapSource as WP4SourceMapSource, 5 | } from 'webpack-sources'; 6 | import type webpack4 from 'webpack'; 7 | import type webpack5 from 'webpack5'; 8 | import ModuleFilenameHelpers from 'webpack/lib/ModuleFilenameHelpers.js'; 9 | import { version } from '../package.json'; 10 | import type { EsbuildPluginOptions } from './types.js'; 11 | 12 | type Compiler = webpack4.Compiler | webpack5.Compiler; 13 | type Compilation = webpack4.compilation.Compilation | webpack5.Compilation; 14 | type Asset = webpack4.compilation.Asset | Readonly; 15 | type EsbuildTransform = typeof defaultEsbuildTransform; 16 | 17 | const isJsFile = /\.[cm]?js(?:\?.*)?$/i; 18 | const isCssFile = /\.css(?:\?.*)?$/i; 19 | const pluginName = 'EsbuildPlugin'; 20 | 21 | const transformAssets = async ( 22 | options: EsbuildPluginOptions, 23 | transform: EsbuildTransform, 24 | compilation: Compilation, 25 | useSourceMap: boolean, 26 | ) => { 27 | const { compiler } = compilation; 28 | const sources = 'webpack' in compiler && compiler.webpack.sources; 29 | const SourceMapSource = (sources ? sources.SourceMapSource : WP4SourceMapSource); 30 | const RawSource = (sources ? sources.RawSource : WP4RawSource); 31 | 32 | const { 33 | css: minifyCss, 34 | include, 35 | exclude, 36 | implementation, 37 | ...transformOptions 38 | } = options; 39 | 40 | const minimized = ( 41 | transformOptions.minify 42 | || transformOptions.minifyWhitespace 43 | || transformOptions.minifyIdentifiers 44 | || transformOptions.minifySyntax 45 | ); 46 | 47 | const assets = (compilation.getAssets() as Asset[]).filter(asset => ( 48 | 49 | // Filter out already minimized 50 | !asset.info.minimized 51 | 52 | // Filter out by file type 53 | && ( 54 | isJsFile.test(asset.name) 55 | || (minifyCss && isCssFile.test(asset.name)) 56 | ) 57 | && ModuleFilenameHelpers.matchObject( 58 | { 59 | include, 60 | exclude, 61 | }, 62 | asset.name, 63 | ) 64 | )); 65 | 66 | await Promise.all(assets.map(async (asset) => { 67 | const assetIsCss = isCssFile.test(asset.name); 68 | let source: string | Buffer | ArrayBuffer; 69 | let map = null; 70 | if (useSourceMap) { 71 | if (asset.source.sourceAndMap) { 72 | const sourceAndMap = asset.source.sourceAndMap(); 73 | source = sourceAndMap.source; 74 | map = sourceAndMap.map; 75 | } else { 76 | source = asset.source.source(); 77 | if (asset.source.map) { 78 | map = asset.source.map(); 79 | } 80 | } 81 | } else { 82 | source = asset.source.source(); 83 | } 84 | const sourceAsString = source.toString(); 85 | const result = await transform(sourceAsString, { 86 | ...transformOptions, 87 | loader: ( 88 | assetIsCss 89 | ? 'css' 90 | : transformOptions.loader 91 | ), 92 | sourcemap: useSourceMap, 93 | sourcefile: asset.name, 94 | }); 95 | 96 | if (result.legalComments) { 97 | compilation.emitAsset( 98 | `${asset.name}.LEGAL.txt`, 99 | new RawSource(result.legalComments) as webpack5.sources.Source, 100 | ); 101 | } 102 | 103 | compilation.updateAsset( 104 | asset.name, 105 | ( 106 | // @ts-expect-error complex webpack union type for source 107 | result.map 108 | ? new SourceMapSource( 109 | result.code, 110 | asset.name, 111 | // @ts-expect-error it accepts strings 112 | result.map, 113 | sourceAsString, 114 | map, 115 | true, 116 | ) 117 | : new RawSource(result.code) 118 | ), 119 | { 120 | ...asset.info, 121 | minimized, 122 | }, 123 | ); 124 | })); 125 | }; 126 | 127 | export default class EsbuildPlugin { 128 | options: EsbuildPluginOptions; 129 | 130 | constructor( 131 | options: EsbuildPluginOptions = {}, 132 | ) { 133 | const { implementation } = options; 134 | if ( 135 | implementation 136 | && typeof implementation.transform !== 'function' 137 | ) { 138 | throw new TypeError( 139 | `[${pluginName}] implementation.transform must be an esbuild transform function. Received ${typeof implementation.transform}`, 140 | ); 141 | } 142 | 143 | this.options = options; 144 | } 145 | 146 | apply(compiler: Compiler) { 147 | const { 148 | implementation, 149 | ...options 150 | } = this.options; 151 | const transform = implementation?.transform ?? defaultEsbuildTransform; 152 | 153 | if (!('format' in options)) { 154 | const { target } = compiler.options; 155 | const isWebTarget = ( 156 | Array.isArray(target) 157 | ? target.includes('web') 158 | : target === 'web' 159 | ); 160 | const wontGenerateHelpers = !options.target || ( 161 | Array.isArray(options.target) 162 | ? ( 163 | options.target.length === 1 164 | && options.target[0] === 'esnext' 165 | ) 166 | : options.target === 'esnext' 167 | ); 168 | 169 | if (isWebTarget && !wontGenerateHelpers) { 170 | options.format = 'iife'; 171 | } 172 | } 173 | 174 | /** 175 | * Enable minification by default if used in the minimizer array 176 | * unless further specified in the options 177 | */ 178 | const usedAsMinimizer = compiler.options.optimization?.minimizer?.includes?.(this); 179 | if ( 180 | usedAsMinimizer 181 | && !( 182 | 'minify' in options 183 | || 'minifyWhitespace' in options 184 | || 'minifyIdentifiers' in options 185 | || 'minifySyntax' in options 186 | ) 187 | ) { 188 | options.minify = compiler.options.optimization?.minimize; 189 | } 190 | 191 | compiler.hooks.compilation.tap(pluginName, (compilation) => { 192 | const meta = JSON.stringify({ 193 | name: 'esbuild-loader', 194 | version, 195 | options, 196 | }); 197 | 198 | compilation.hooks.chunkHash.tap( 199 | pluginName, 200 | (_, hash) => hash.update(meta), 201 | ); 202 | 203 | /** 204 | * Check if sourcemaps are enabled 205 | * Webpack 4: https://github.com/webpack/webpack/blob/v4.46.0/lib/SourceMapDevToolModuleOptionsPlugin.js#L20 206 | * Webpack 5: https://github.com/webpack/webpack/blob/v5.75.0/lib/SourceMapDevToolModuleOptionsPlugin.js#LL27 207 | */ 208 | let useSourceMap = false; 209 | 210 | /** 211 | * `finishModules` hook is called after all the `buildModule` hooks are called, 212 | * which is where the `useSourceMap` flag is set 213 | * https://webpack.js.org/api/compilation-hooks/#finishmodules 214 | */ 215 | compilation.hooks.finishModules.tap( 216 | pluginName, 217 | (modules) => { 218 | const firstModule = ( 219 | Array.isArray(modules) 220 | ? modules[0] 221 | : (modules as Set).values().next().value as webpack5.Module 222 | ); 223 | if (firstModule) { 224 | useSourceMap = firstModule.useSourceMap; 225 | } 226 | }, 227 | ); 228 | 229 | // Webpack 5 230 | if ('processAssets' in compilation.hooks) { 231 | compilation.hooks.processAssets.tapPromise( 232 | { 233 | name: pluginName, 234 | // @ts-expect-error undefined on Function type 235 | stage: compilation.constructor.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE, 236 | additionalAssets: true, 237 | }, 238 | () => transformAssets(options, transform, compilation, useSourceMap), 239 | ); 240 | 241 | compilation.hooks.statsPrinter.tap(pluginName, (statsPrinter) => { 242 | statsPrinter.hooks.print 243 | .for('asset.info.minimized') 244 | .tap( 245 | pluginName, 246 | ( 247 | minimized, 248 | { green, formatFlag }, 249 | // @ts-expect-error type incorrectly doesn't accept undefined 250 | ) => ( 251 | minimized 252 | // @ts-expect-error type incorrectly doesn't accept undefined 253 | ? green(formatFlag('minimized')) 254 | : undefined 255 | ), 256 | ); 257 | }); 258 | } else { 259 | compilation.hooks.optimizeChunkAssets.tapPromise( 260 | pluginName, 261 | () => transformAssets(options, transform, compilation, useSourceMap), 262 | ); 263 | } 264 | }); 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { transform, type TransformOptions } from 'esbuild'; 2 | 3 | type Filter = string | RegExp; 4 | 5 | type Implementation = { 6 | transform: typeof transform; 7 | }; 8 | 9 | type Except = { 10 | [Key in keyof ObjectType as (Key extends Properties ? never : Key)]: ObjectType[Key]; 11 | }; 12 | 13 | export type LoaderOptions = Except & { 14 | 15 | /** Pass a custom esbuild implementation */ 16 | implementation?: Implementation; 17 | 18 | /** 19 | * Path to tsconfig.json file 20 | */ 21 | tsconfig?: string; 22 | }; 23 | 24 | export type EsbuildPluginOptions = Except & { 25 | include?: Filter | Filter[]; 26 | exclude?: Filter | Filter[]; 27 | css?: boolean; 28 | 29 | /** Pass a custom esbuild implementation */ 30 | implementation?: Implementation; 31 | }; 32 | -------------------------------------------------------------------------------- /tests/fixtures.ts: -------------------------------------------------------------------------------- 1 | export const exportFile = ( 2 | name: string, 3 | code: string, 4 | ) => ({ 5 | '/src/index.js': `export { default } from "./${name}"`, 6 | [`/src/${name}`]: code, 7 | }); 8 | 9 | const trySyntax = ( 10 | name: string, 11 | code: string, 12 | ) => ` 13 | (() => { 14 | try { 15 | ${code} 16 | return ${JSON.stringify(name)}; 17 | } catch (error) { 18 | return error; 19 | } 20 | })() 21 | `; 22 | 23 | export const js = exportFile( 24 | 'js.js', 25 | `export default [${[ 26 | trySyntax( 27 | 'es2016 - Exponentiation operator', 28 | '10 ** 4', 29 | ), 30 | 31 | trySyntax( 32 | 'es2017 - Async functions', 33 | 'typeof (async () => {})', 34 | ), 35 | 36 | // trySyntax( 37 | // 'es2018 - Asynchronous iteration', 38 | // 'for await (let x of []) {}', 39 | // ), 40 | 41 | trySyntax( 42 | 'es2018 - Spread properties', 43 | 'let x = {...Object}', 44 | ), 45 | 46 | trySyntax( 47 | 'es2018 - Rest properties', 48 | 'let {...x} = Object', 49 | ), 50 | 51 | trySyntax( 52 | 'es2019 - Optional catch binding', 53 | 'try {} catch {}', 54 | ), 55 | 56 | trySyntax( 57 | 'es2020 - Optional chaining', 58 | 'Object?.keys', 59 | ), 60 | 61 | trySyntax( 62 | 'es2020 - Nullish coalescing', 63 | 'Object ?? true', 64 | ), 65 | 66 | trySyntax( 67 | 'es2020 - import.meta', 68 | 'import.meta', 69 | ), 70 | 71 | trySyntax( 72 | 'es2021 - Logical assignment operators', 73 | 'let a = false; a ??= true; a ||= true; a &&= true;', 74 | ), 75 | 76 | trySyntax( 77 | 'es2022 - Class instance fields', 78 | '(class { x })', 79 | ), 80 | 81 | trySyntax( 82 | 'es2022 - Static class fields', 83 | '(class { static x })', 84 | ), 85 | 86 | trySyntax( 87 | 'es2022 - Private instance methods', 88 | '(class { #x() {} })', 89 | ), 90 | 91 | trySyntax( 92 | 'es2022 - Private instance fields', 93 | '(class { #x })', 94 | ), 95 | 96 | trySyntax( 97 | 'es2022 - Private static methods', 98 | '(class { static #x() {} })', 99 | ), 100 | 101 | trySyntax( 102 | 'es2022 - Private static fields', 103 | '(class { static #x })', 104 | ), 105 | 106 | // trySyntax( 107 | // 'es2022 - Ergonomic brand checks', 108 | // '(class { #brand; static isC(obj) { return try obj.#brand; } })', 109 | // ), 110 | 111 | trySyntax( 112 | 'es2022 - Class static blocks', 113 | '(class { static {} })', 114 | ), 115 | 116 | // trySyntax( 117 | // 'esnext - Import assertions', 118 | // 'import "x" assert {}', 119 | // ), 120 | 121 | ].join(',')}];`, 122 | ); 123 | 124 | export const ts = exportFile( 125 | 'ts.ts', 126 | ` 127 | import type {Type} from 'foo' 128 | 129 | interface Foo {} 130 | 131 | type Foo = number 132 | 133 | declare module 'foo' {} 134 | 135 | enum BasicEnum { 136 | Left, 137 | Right, 138 | } 139 | 140 | enum NamedEnum { 141 | SomeEnum = 'some-value', 142 | } 143 | 144 | export const a = BasicEnum.Left; 145 | 146 | export const b = NamedEnum.SomeEnum; 147 | 148 | export default function foo(): string { 149 | return 'foo' 150 | } 151 | 152 | // For "ts as tsx" test 153 | const bar = (value: T) => fn(); 154 | `, 155 | ); 156 | 157 | export const blank = { 158 | '/src/index.js': '', 159 | }; 160 | 161 | export const minification = { 162 | '/src/index.js': 'export default ( stringVal ) => { return stringVal }', 163 | }; 164 | 165 | export const define = { 166 | '/src/index.js': 'export default () => [__TEST1__, __TEST2__]', 167 | }; 168 | 169 | export const getHelpers = { 170 | '/src/index.js': 'export default async () => {}', 171 | }; 172 | 173 | export const legalComments = { 174 | '/src/index.js': ` 175 | //! legal comment 176 | globalCall(); 177 | `, 178 | }; 179 | 180 | export const css = { 181 | '/src/index.js': 'import "./styles.css"', 182 | '/src/styles.css': ` 183 | div { 184 | color: red; 185 | } 186 | span { 187 | margin: 0px 10px; 188 | } 189 | `, 190 | }; 191 | -------------------------------------------------------------------------------- /tests/index.ts: -------------------------------------------------------------------------------- 1 | import { describe } from 'manten'; 2 | import webpack4 from 'webpack'; 3 | import webpack5 from 'webpack5'; 4 | 5 | const webpacks = [ 6 | webpack4, 7 | webpack5, 8 | ]; 9 | 10 | describe('esbuild-loader', ({ describe, runTestSuite }) => { 11 | for (const webpack of webpacks) { 12 | describe(`Webpack ${webpack.version![0]}`, ({ runTestSuite }) => { 13 | runTestSuite(import('./specs/loader.js'), webpack); 14 | runTestSuite(import('./specs/plugin.js'), webpack); 15 | }); 16 | } 17 | 18 | runTestSuite(import('./specs/tsconfig.js')); 19 | runTestSuite(import('./specs/webpack5.js')); 20 | }); 21 | -------------------------------------------------------------------------------- /tests/specs/loader.ts: -------------------------------------------------------------------------------- 1 | import { testSuite, expect } from 'manten'; 2 | import { build } from 'webpack-test-utils'; 3 | import webpack4 from 'webpack'; 4 | import webpack5 from 'webpack5'; 5 | import { 6 | type Webpack, 7 | configureEsbuildLoader, 8 | configureCssLoader, 9 | } from '../utils.js'; 10 | import * as fixtures from '../fixtures.js'; 11 | import type { EsbuildPluginOptions } from '#esbuild-loader'; 12 | 13 | const { exportFile } = fixtures; 14 | 15 | export default testSuite(({ describe }, webpack: typeof webpack4 | typeof webpack5) => { 16 | describe('Loader', ({ test, describe }) => { 17 | describe('Error handling', ({ test }) => { 18 | test('tsx fails to be parsed as ts', async () => { 19 | const built = await build( 20 | exportFile( 21 | 'tsx.tsx', 22 | 'export default
hello world
', 23 | ), 24 | (config) => { 25 | configureEsbuildLoader(config, { 26 | test: /\.tsx$/, 27 | options: { 28 | loader: 'ts', 29 | }, 30 | }); 31 | }, 32 | webpack, 33 | ); 34 | 35 | expect(built.stats.hasErrors()).toBe(true); 36 | const [error] = built.stats.compilation.errors; 37 | expect(error.message).toMatch('Transform failed with 1 error'); 38 | }); 39 | }); 40 | 41 | test('transforms syntax', async () => { 42 | const built = await build( 43 | fixtures.js, 44 | configureEsbuildLoader, 45 | webpack, 46 | ); 47 | 48 | expect(built.stats.hasWarnings()).toBe(false); 49 | expect(built.stats.hasErrors()).toBe(false); 50 | expect(built.require('/dist')).toStrictEqual([ 51 | 'es2016 - Exponentiation operator', 52 | 'es2017 - Async functions', 53 | 'es2018 - Spread properties', 54 | 'es2018 - Rest properties', 55 | 'es2019 - Optional catch binding', 56 | 'es2020 - Optional chaining', 57 | 'es2020 - Nullish coalescing', 58 | 'es2020 - import.meta', 59 | 'es2021 - Logical assignment operators', 60 | 'es2022 - Class instance fields', 61 | 'es2022 - Static class fields', 62 | 'es2022 - Private instance methods', 63 | 'es2022 - Private instance fields', 64 | 'es2022 - Private static methods', 65 | 'es2022 - Private static fields', 66 | 'es2022 - Class static blocks', 67 | ]); 68 | }); 69 | 70 | test('transforms TypeScript', async () => { 71 | const built = await build( 72 | fixtures.ts, 73 | (config) => { 74 | configureEsbuildLoader(config, { 75 | test: /\.ts$/, 76 | }); 77 | }, 78 | webpack, 79 | ); 80 | 81 | expect(built.stats.hasWarnings()).toBe(false); 82 | expect(built.stats.hasErrors()).toBe(false); 83 | 84 | expect(built.require('/dist')()).toBe('foo'); 85 | }); 86 | 87 | test('transforms TSX', async () => { 88 | const built = await build( 89 | exportFile( 90 | 'tsx.tsx', 91 | 'export default (<>
hello world
)', 92 | ), 93 | (config) => { 94 | configureEsbuildLoader(config, { 95 | test: /\.tsx$/, 96 | options: { 97 | jsxFactory: 'Array', 98 | jsxFragment: '"Fragment"', 99 | }, 100 | }); 101 | }, 102 | webpack, 103 | ); 104 | 105 | expect(built.stats.hasWarnings()).toBe(false); 106 | expect(built.stats.hasErrors()).toBe(false); 107 | 108 | expect(built.require('/dist')).toStrictEqual([ 109 | 'Fragment', 110 | null, 111 | [ 112 | 'div', 113 | null, 114 | 'hello world', 115 | ], 116 | ]); 117 | }); 118 | 119 | test('tsconfig', async () => { 120 | const built = await build( 121 | exportFile( 122 | 'tsx.tsx', 123 | 'export default (
hello world
)', 124 | ), 125 | (config) => { 126 | configureEsbuildLoader(config, { 127 | test: /\.tsx$/, 128 | options: { 129 | tsconfigRaw: { 130 | compilerOptions: { 131 | jsxFactory: 'Array', 132 | }, 133 | }, 134 | }, 135 | }); 136 | }, 137 | webpack, 138 | ); 139 | 140 | expect(built.stats.hasWarnings()).toBe(false); 141 | expect(built.stats.hasErrors()).toBe(false); 142 | expect(built.require('/dist/index.js')).toStrictEqual(['div', null, 'hello world']); 143 | }); 144 | 145 | describe('implementation', ({ test }) => { 146 | test('error', async () => { 147 | const runWithImplementation = async ( 148 | implementation: EsbuildPluginOptions['implementation'], 149 | ) => { 150 | const built = await build( 151 | fixtures.blank, 152 | (config) => { 153 | configureEsbuildLoader(config, { 154 | options: { 155 | implementation, 156 | }, 157 | }); 158 | }, 159 | webpack, 160 | ); 161 | 162 | expect(built.stats.hasErrors()).toBe(true); 163 | const [error] = built.stats.compilation.errors; 164 | throw error; 165 | }; 166 | 167 | // @ts-expect-error testing invalid type 168 | await expect(runWithImplementation({})).rejects.toThrow( 169 | 'esbuild-loader: options.implementation.transform must be an ESBuild transform function. Received undefined', 170 | ); 171 | 172 | // @ts-expect-error testing invalid type 173 | await expect(runWithImplementation({ transform: 123 })).rejects.toThrow( 174 | 'esbuild-loader: options.implementation.transform must be an ESBuild transform function. Received number', 175 | ); 176 | }); 177 | 178 | test('custom transform function', async () => { 179 | const built = await build( 180 | fixtures.blank, 181 | (config) => { 182 | configureEsbuildLoader(config, { 183 | options: { 184 | implementation: { 185 | transform: async () => ({ 186 | code: 'export default "CUSTOM_ESBUILD_IMPLEMENTATION"', 187 | map: '', 188 | warnings: [], 189 | }), 190 | }, 191 | }, 192 | }); 193 | }, 194 | webpack, 195 | ); 196 | 197 | expect(built.stats.hasWarnings()).toBe(false); 198 | expect(built.stats.hasErrors()).toBe(false); 199 | 200 | const dist = built.fs.readFileSync('/dist/index.js', 'utf8'); 201 | expect(dist).toContain('CUSTOM_ESBUILD_IMPLEMENTATION'); 202 | }); 203 | }); 204 | 205 | describe('ambigious ts/tsx', () => { 206 | test('ts via tsx', async () => { 207 | const built = await build( 208 | fixtures.ts, 209 | (config) => { 210 | configureEsbuildLoader(config, { 211 | test: /\.tsx?$/, 212 | }); 213 | }, 214 | webpack, 215 | ); 216 | 217 | expect(built.stats.hasWarnings()).toBe(false); 218 | expect(built.stats.hasErrors()).toBe(false); 219 | 220 | expect(built.require('/dist')()).toBe('foo'); 221 | }); 222 | 223 | test('ts via tsx 2', async () => { 224 | const built = await build( 225 | exportFile( 226 | 'ts.ts', ` 227 | export default ( 228 | l: obj, 229 | options: { [key in obj]: V }, 230 | ): V => { 231 | return options[l]; 232 | }; 233 | `, 234 | ), 235 | (config) => { 236 | configureEsbuildLoader(config, { 237 | test: /\.tsx?$/, 238 | }); 239 | }, 240 | webpack, 241 | ); 242 | 243 | expect(built.stats.hasWarnings()).toBe(false); 244 | expect(built.stats.hasErrors()).toBe(false); 245 | 246 | expect(built.require('/dist')('a', { a: 1 })).toBe(1); 247 | }); 248 | 249 | test('ambiguous ts', async () => { 250 | const built = await build( 251 | exportFile( 252 | 'ts.ts', 253 | 'export default () => 1/g', 254 | ), 255 | (config) => { 256 | configureEsbuildLoader(config, { 257 | test: /\.tsx?$/, 258 | }); 259 | }, 260 | webpack, 261 | ); 262 | 263 | expect(built.stats.hasWarnings()).toBe(false); 264 | expect(built.stats.hasErrors()).toBe(false); 265 | 266 | const dist = built.fs.readFileSync('/dist/index.js', 'utf8'); 267 | expect(dist).toContain('(() => 1 < /a>/g)'); 268 | }); 269 | 270 | test('ambiguous tsx', async () => { 271 | const built = await build( 272 | exportFile( 273 | 'tsx.tsx', 274 | 'export default () => 1/g', 275 | ), 276 | (config) => { 277 | configureEsbuildLoader(config, { 278 | test: /\.tsx?$/, 279 | }); 280 | }, 281 | webpack, 282 | ); 283 | 284 | expect(built.stats.hasWarnings()).toBe(false); 285 | expect(built.stats.hasErrors()).toBe(false); 286 | 287 | const dist = built.fs.readFileSync('/dist/index.js', 'utf8'); 288 | expect(dist).toContain('React.createElement'); 289 | }); 290 | }); 291 | 292 | describe('Source-map', ({ test }) => { 293 | test('source-map eval', async () => { 294 | const built = await build( 295 | fixtures.js, 296 | (config) => { 297 | configureEsbuildLoader(config); 298 | config.devtool = 'eval-source-map'; 299 | }, 300 | webpack, 301 | ); 302 | 303 | expect(built.stats.hasWarnings()).toBe(false); 304 | expect(built.stats.hasErrors()).toBe(false); 305 | 306 | const dist = built.fs.readFileSync('/dist/index.js', 'utf8'); 307 | expect(dist).toContain('eval('); 308 | }); 309 | 310 | test('source-map inline', async () => { 311 | const built = await build( 312 | fixtures.js, 313 | (config) => { 314 | configureEsbuildLoader(config); 315 | config.devtool = 'inline-source-map'; 316 | }, 317 | webpack, 318 | ); 319 | 320 | expect(built.stats.hasWarnings()).toBe(false); 321 | expect(built.stats.hasErrors()).toBe(false); 322 | 323 | const dist = built.fs.readFileSync('/dist/index.js', 'utf8'); 324 | expect(dist).toContain('sourceMappingURL'); 325 | }); 326 | 327 | test('source-map file', async () => { 328 | const built = await build( 329 | fixtures.js, 330 | (config) => { 331 | configureEsbuildLoader(config); 332 | config.devtool = 'source-map'; 333 | }, 334 | webpack, 335 | ); 336 | 337 | expect(built.stats.hasWarnings()).toBe(false); 338 | expect(built.stats.hasErrors()).toBe(false); 339 | 340 | const { assets } = built.stats.compilation; 341 | expect(assets).toHaveProperty(['index.js']); 342 | expect(assets).toHaveProperty(['index.js.map']); 343 | }); 344 | 345 | test('source-map plugin', async () => { 346 | const built = await build( 347 | fixtures.js, 348 | (config) => { 349 | configureEsbuildLoader(config); 350 | 351 | delete config.devtool; 352 | config.plugins!.push( 353 | new webpack.SourceMapDevToolPlugin({}) as Webpack['SourceMapDevToolPlugin'], 354 | ); 355 | }, 356 | webpack, 357 | ); 358 | 359 | expect(built.stats.hasWarnings()).toBe(false); 360 | expect(built.stats.hasErrors()).toBe(false); 361 | 362 | const dist = built.fs.readFileSync('/dist/index.js', 'utf8'); 363 | expect(dist).toContain('sourceMappingURL'); 364 | }); 365 | }); 366 | 367 | test('webpack magic comments', async () => { 368 | const built = await build({ 369 | '/src/index.js': ` 370 | const chunkA = import(/* webpackChunkName: "named-chunk-foo" */'./chunk-a.js') 371 | const chunkB = import(/* webpackChunkName: "named-chunk-bar" */'./chunk-b.js') 372 | export default async () => (await chunkA).default + (await chunkB).default; 373 | `, 374 | '/src/chunk-a.js': 'export default 1', 375 | '/src/chunk-b.js': 'export default 2', 376 | }, configureEsbuildLoader, webpack); 377 | 378 | expect(built.stats.hasWarnings()).toBe(false); 379 | expect(built.stats.hasErrors()).toBe(false); 380 | 381 | const { assets } = built.stats.compilation; 382 | expect(assets).toHaveProperty(['index.js']); 383 | expect(assets).toHaveProperty(['named-chunk-foo.js']); 384 | expect(assets).toHaveProperty(['named-chunk-bar.js']); 385 | expect(await built.require('/dist')()).toBe(3); 386 | }); 387 | 388 | test('CSS minification', async () => { 389 | const built = await build( 390 | fixtures.css, 391 | (config) => { 392 | configureEsbuildLoader(config); 393 | const cssRule = configureCssLoader(config); 394 | cssRule.use.push({ 395 | loader: 'esbuild-loader', 396 | options: { 397 | minify: true, 398 | }, 399 | }); 400 | }, 401 | webpack, 402 | ); 403 | 404 | expect(built.stats.hasWarnings()).toBe(false); 405 | expect(built.stats.hasErrors()).toBe(false); 406 | 407 | const code = built.fs.readFileSync('/dist/index.js', 'utf8'); 408 | expect(code).toContain('div{color:red}'); 409 | }); 410 | 411 | test('Keeps dynamic imports by default', async () => { 412 | const built = await build( 413 | { 414 | '/src/index.js': 'export default async () => (await import("./test2.js")).default', 415 | '/src/test2.js': 'export default "test2"', 416 | }, 417 | (config) => { 418 | configureEsbuildLoader(config, { options: { target: 'chrome52' } }); 419 | }, 420 | webpack, 421 | ); 422 | 423 | expect(built.stats.hasWarnings()).toBe(false); 424 | expect(built.stats.hasErrors()).toBe(false); 425 | 426 | const { assets } = built.stats.compilation; 427 | expect(assets).toHaveProperty(['index.js']); 428 | 429 | // Chunk split because esbuild preserved the dynamic import 430 | expect(Object.keys(assets).length).toBe(2); 431 | expect(await built.require('/dist')()).toBe('test2'); 432 | }); 433 | 434 | test('Dynamic imports can be disabled', async () => { 435 | const built = await build( 436 | { 437 | '/src/index.js': 'export default async () => (await import("./test2.js")).default', 438 | '/src/test2.js': 'export default "test2"', 439 | }, 440 | (config) => { 441 | configureEsbuildLoader(config, { 442 | options: { 443 | target: 'chrome52', 444 | supported: { 'dynamic-import': false }, 445 | }, 446 | }); 447 | }, 448 | webpack, 449 | ); 450 | 451 | expect(built.stats.hasWarnings()).toBe(false); 452 | expect(built.stats.hasErrors()).toBe(false); 453 | 454 | const { assets } = built.stats.compilation; 455 | expect(assets).toHaveProperty(['index.js']); 456 | 457 | // No chunk split because esbuild removed the dynamic import 458 | expect(Object.keys(assets).length).toBe(1); 459 | expect(await built.require('/dist')()).toBe('test2'); 460 | }); 461 | }); 462 | }); 463 | -------------------------------------------------------------------------------- /tests/specs/plugin.ts: -------------------------------------------------------------------------------- 1 | import { testSuite, expect } from 'manten'; 2 | import { build } from 'webpack-test-utils'; 3 | import webpack4 from 'webpack'; 4 | import webpack5 from 'webpack5'; 5 | import * as esbuild from 'esbuild'; 6 | import { merge } from 'webpack-merge'; 7 | import { 8 | type Webpack, 9 | isWebpack4, 10 | configureEsbuildMinifyPlugin, 11 | configureMiniCssExtractPlugin, 12 | } from '../utils.js'; 13 | import * as fixtures from '../fixtures.js'; 14 | import { EsbuildPlugin, type EsbuildPluginOptions } from '#esbuild-loader'; 15 | 16 | const assertMinified = (code: string) => { 17 | expect(code).not.toMatch(/\s{2,}/); 18 | expect(code).not.toMatch('stringVal'); 19 | expect(code).not.toMatch('return '); 20 | }; 21 | 22 | const countIife = (code: string) => Array.from(code.matchAll(/\(\(\)=>\{/g)).length; 23 | 24 | export default testSuite(({ describe }, webpack: typeof webpack4 | typeof webpack5) => { 25 | const webpackIs4 = isWebpack4(webpack); 26 | 27 | describe('Plugin', ({ test, describe }) => { 28 | describe('Minify JS', ({ test, describe }) => { 29 | describe('should not minify by default', ({ test }) => { 30 | test('minimizer', async () => { 31 | const built = await build( 32 | fixtures.minification, 33 | (config) => { 34 | config.optimization = { 35 | minimize: false, 36 | minimizer: [ 37 | new EsbuildPlugin(), 38 | ], 39 | }; 40 | }, 41 | webpack, 42 | ); 43 | 44 | expect(built.stats.hasWarnings()).toBe(false); 45 | expect(built.stats.hasErrors()).toBe(false); 46 | 47 | const exportedFunction = built.require('/dist/'); 48 | expect(exportedFunction('hello world')).toBe('hello world'); 49 | expect(exportedFunction.toString()).toMatch(/\s{2,}/); 50 | }); 51 | 52 | test('plugin', async () => { 53 | const built = await build( 54 | fixtures.minification, 55 | (config) => { 56 | config.plugins?.push(new EsbuildPlugin()); 57 | }, 58 | webpack, 59 | ); 60 | 61 | expect(built.stats.hasWarnings()).toBe(false); 62 | expect(built.stats.hasErrors()).toBe(false); 63 | 64 | const exportedFunction = built.require('/dist/'); 65 | expect(exportedFunction('hello world')).toBe('hello world'); 66 | expect(exportedFunction.toString()).toMatch(/\s{2,}/); 67 | }); 68 | 69 | test('plugin with minimize enabled', async () => { 70 | const built = await build( 71 | fixtures.minification, 72 | (config) => { 73 | config.optimization = { 74 | minimize: true, 75 | 76 | // Remove Terser 77 | minimizer: [], 78 | }; 79 | 80 | config.plugins?.push(new EsbuildPlugin()); 81 | }, 82 | webpack, 83 | ); 84 | 85 | expect(built.stats.hasWarnings()).toBe(false); 86 | expect(built.stats.hasErrors()).toBe(false); 87 | 88 | const exportedFunction = built.require('/dist/'); 89 | expect(exportedFunction('hello world')).toBe('hello world'); 90 | expect(exportedFunction.toString()).toMatch(/\s{2,}/); 91 | }); 92 | }); 93 | 94 | test('minify', async () => { 95 | const built = await build( 96 | fixtures.minification, 97 | (config) => { 98 | configureEsbuildMinifyPlugin(config); 99 | }, 100 | webpack, 101 | ); 102 | 103 | expect(built.stats.hasWarnings()).toBe(false); 104 | expect(built.stats.hasErrors()).toBe(false); 105 | 106 | const exportedFunction = built.require('/dist/'); 107 | expect(exportedFunction('hello world')).toBe('hello world'); 108 | assertMinified(exportedFunction.toString()); 109 | }); 110 | 111 | test('minifyWhitespace', async () => { 112 | const built = await build( 113 | fixtures.minification, 114 | (config) => { 115 | configureEsbuildMinifyPlugin(config, { 116 | minifyWhitespace: true, 117 | }); 118 | }, 119 | webpack, 120 | ); 121 | 122 | expect(built.stats.hasWarnings()).toBe(false); 123 | expect(built.stats.hasErrors()).toBe(false); 124 | 125 | const exportedFunction = built.require('/dist/'); 126 | expect(exportedFunction('hello world')).toBe('hello world'); 127 | 128 | const code = exportedFunction.toString(); 129 | expect(code).not.toMatch(/\s{2,}/); 130 | expect(code).toMatch('stringVal'); 131 | expect(code).toMatch('return '); 132 | }); 133 | 134 | test('minifyIdentifiers', async () => { 135 | const built = await build( 136 | fixtures.minification, 137 | (config) => { 138 | configureEsbuildMinifyPlugin(config, { 139 | minifyIdentifiers: true, 140 | }); 141 | }, 142 | webpack, 143 | ); 144 | 145 | expect(built.stats.hasWarnings()).toBe(false); 146 | expect(built.stats.hasErrors()).toBe(false); 147 | 148 | const exportedFunction = built.require('/dist/'); 149 | expect(exportedFunction('hello world')).toBe('hello world'); 150 | 151 | const code = exportedFunction.toString(); 152 | expect(code).toMatch(/\s{2,}/); 153 | expect(code).not.toMatch('stringVal'); 154 | expect(code).toMatch('return '); 155 | }); 156 | 157 | test('minifySyntax', async () => { 158 | const built = await build( 159 | fixtures.minification, 160 | (config) => { 161 | configureEsbuildMinifyPlugin(config, { 162 | minifySyntax: true, 163 | }); 164 | }, 165 | webpack, 166 | ); 167 | 168 | expect(built.stats.hasWarnings()).toBe(false); 169 | expect(built.stats.hasErrors()).toBe(false); 170 | 171 | const exportedFunction = built.require('/dist/'); 172 | expect(exportedFunction('hello world')).toBe('hello world'); 173 | 174 | const code = exportedFunction.toString(); 175 | expect(code).toMatch(/\s/); 176 | expect(code).toMatch('stringVal'); 177 | expect(code).not.toMatch('return '); 178 | }); 179 | 180 | test('should minify when used alongside plugin', async () => { 181 | const built = await build( 182 | fixtures.minification, 183 | (config) => { 184 | configureEsbuildMinifyPlugin(config); 185 | config.plugins?.push(new EsbuildPlugin()); 186 | }, 187 | webpack, 188 | ); 189 | 190 | expect(built.stats.hasWarnings()).toBe(false); 191 | expect(built.stats.hasErrors()).toBe(false); 192 | 193 | const exportedFunction = built.require('/dist/'); 194 | expect(exportedFunction('hello world')).toBe('hello world'); 195 | assertMinified(exportedFunction.toString()); 196 | }); 197 | 198 | test('minify chunks & filter using include/exclude', async () => { 199 | const built = await build({ 200 | '/src/index.js': ` 201 | const foo = import(/* webpackChunkName: "named-chunk-foo" */'./foo.js') 202 | const bar = import(/* webpackChunkName: "named-chunk-bar" */'./bar.js') 203 | const baz = import(/* webpackChunkName: "named-chunk-baz" */'./baz.js') 204 | export default [foo, bar, baz]; 205 | `, 206 | '/src/foo.js': fixtures.minification['/src/index.js'], 207 | '/src/bar.js': fixtures.minification['/src/index.js'], 208 | '/src/baz.js': fixtures.minification['/src/index.js'], 209 | }, (config) => { 210 | configureEsbuildMinifyPlugin(config, { 211 | include: /ba./, 212 | exclude: /baz/, 213 | }); 214 | }, webpack); 215 | 216 | expect(built.stats.hasWarnings()).toBe(false); 217 | expect(built.stats.hasErrors()).toBe(false); 218 | 219 | const chunkFoo = built.fs.readFileSync('/dist/named-chunk-foo.js', 'utf8').toString(); 220 | 221 | // The string "__webpack_require__" is only present in unminified chunks 222 | expect(chunkFoo).toContain('__webpack_require__'); 223 | 224 | const chunkBar = built.fs.readFileSync('/dist/named-chunk-bar.js', 'utf8').toString(); 225 | expect(chunkBar).not.toContain('__webpack_require__'); 226 | assertMinified(chunkBar); 227 | 228 | const chunkBaz = built.fs.readFileSync('/dist/named-chunk-baz.js', 'utf8').toString(); 229 | expect(chunkBaz).toContain('__webpack_require__'); 230 | }); 231 | 232 | describe('devtool', ({ test }) => { 233 | test('minify w/ no devtool', async () => { 234 | const built = await build( 235 | fixtures.blank, 236 | (config) => { 237 | delete config.devtool; 238 | configureEsbuildMinifyPlugin(config); 239 | }, 240 | webpack, 241 | ); 242 | 243 | const { stats } = built; 244 | expect(stats.hasWarnings()).toBe(false); 245 | expect(stats.hasErrors()).toBe(false); 246 | expect( 247 | Object.keys(stats.compilation.assets).length, 248 | ).toBe(1); 249 | 250 | const file = built.fs.readFileSync('/dist/index.js', 'utf8'); 251 | expect(file).not.toContain('//# sourceURL'); 252 | }); 253 | 254 | test('minify w/ devtool inline-source-map', async () => { 255 | const built = await build( 256 | fixtures.blank, 257 | (config) => { 258 | config.devtool = 'inline-source-map'; 259 | configureEsbuildMinifyPlugin(config); 260 | }, 261 | webpack, 262 | ); 263 | 264 | const { stats } = built; 265 | expect(stats.hasWarnings()).toBe(false); 266 | expect(stats.hasErrors()).toBe(false); 267 | expect( 268 | Object.keys(stats.compilation.assets).length, 269 | ).toBe(1); 270 | 271 | const file = built.fs.readFileSync('/dist/index.js', 'utf8'); 272 | expect(file).toContain('//# sourceMappingURL=data:application/'); 273 | }); 274 | 275 | test('minify w/ devtool source-map', async () => { 276 | const built = await build( 277 | fixtures.blank, 278 | (config) => { 279 | config.devtool = 'source-map'; 280 | configureEsbuildMinifyPlugin(config); 281 | }, 282 | webpack, 283 | ); 284 | 285 | const { stats } = built; 286 | expect(stats.hasWarnings()).toBe(false); 287 | expect(stats.hasErrors()).toBe(false); 288 | expect( 289 | Object.keys(stats.compilation.assets), 290 | ).toStrictEqual([ 291 | 'index.js', 292 | 'index.js.map', 293 | ]); 294 | 295 | const file = built.fs.readFileSync('/dist/index.js', 'utf8'); 296 | expect(file).toContain('//# sourceMappingURL=index.js.map'); 297 | }); 298 | 299 | test('minify w/ source-map option and source-map plugin inline', async () => { 300 | const built = await build( 301 | fixtures.blank, 302 | (config) => { 303 | delete config.devtool; 304 | configureEsbuildMinifyPlugin(config); 305 | 306 | config.plugins!.push( 307 | new webpack.SourceMapDevToolPlugin({}) as Webpack['SourceMapDevToolPlugin'], 308 | ); 309 | }, 310 | webpack, 311 | ); 312 | 313 | const { stats } = built; 314 | expect(stats.hasWarnings()).toBe(false); 315 | expect(stats.hasErrors()).toBe(false); 316 | expect( 317 | Object.keys(stats.compilation.assets).length, 318 | ).toBe(1); 319 | 320 | const file = built.fs.readFileSync('/dist/index.js', 'utf8'); 321 | expect(file).toContain('//# sourceMappingURL=data:application/'); 322 | }); 323 | 324 | test('minify w/ source-map option and source-map plugin external', async () => { 325 | const built = await build( 326 | fixtures.blank, 327 | (config) => { 328 | delete config.devtool; 329 | configureEsbuildMinifyPlugin(config); 330 | 331 | config.plugins!.push( 332 | new webpack.SourceMapDevToolPlugin({ 333 | filename: 'index.js.map', 334 | }) as Webpack['SourceMapDevToolPlugin'], 335 | ); 336 | }, 337 | webpack, 338 | ); 339 | 340 | const { stats } = built; 341 | expect(stats.hasWarnings()).toBe(false); 342 | expect(stats.hasErrors()).toBe(false); 343 | expect( 344 | Object.keys(stats.compilation.assets), 345 | ).toStrictEqual([ 346 | 'index.js', 347 | 'index.js.map', 348 | ]); 349 | 350 | const file = built.fs.readFileSync('/dist/index.js', 'utf8'); 351 | expect(file).toContain('//# sourceMappingURL=index.js.map'); 352 | }); 353 | }); 354 | 355 | test('minify w/ query strings', async () => { 356 | const built = await build( 357 | { 358 | '/src/index.js': 'import(/* webpackChunkName: "chunk" */"./chunk.js")', 359 | '/src/chunk.js': '', 360 | }, 361 | (config) => { 362 | config.output!.filename = '[name].js?foo=bar'; 363 | config.output!.chunkFilename = '[name].js?foo=bar'; 364 | 365 | configureEsbuildMinifyPlugin(config); 366 | }, 367 | webpack, 368 | ); 369 | 370 | const { stats } = built; 371 | expect(stats.hasWarnings()).toBe(false); 372 | expect(stats.hasErrors()).toBe(false); 373 | expect( 374 | Object.keys(stats.compilation.assets).sort(), 375 | ).toStrictEqual([ 376 | 'chunk.js?foo=bar', 377 | 'index.js?foo=bar', 378 | ]); 379 | 380 | // The actual file name does not include the query string 381 | const file = built.fs.readFileSync('/dist/index.js', 'utf8'); 382 | expect(file).toMatch('?foo=bar'); 383 | }); 384 | 385 | describe('legalComments', ({ test }) => { 386 | test('minify w/ legalComments - default is inline', async () => { 387 | const builtDefault = await build( 388 | fixtures.legalComments, 389 | (config) => { 390 | configureEsbuildMinifyPlugin(config); 391 | }, 392 | webpack, 393 | ); 394 | 395 | const builtInline = await build( 396 | fixtures.legalComments, 397 | (config) => { 398 | configureEsbuildMinifyPlugin(config, { 399 | legalComments: 'inline', 400 | }); 401 | }, 402 | webpack, 403 | ); 404 | 405 | const fileInline = builtInline.fs.readFileSync('/dist/index.js', 'utf8'); 406 | const fileDefault = builtDefault.fs.readFileSync('/dist/index.js', 'utf8'); 407 | 408 | expect(fileDefault).toMatch('//! legal comment'); 409 | expect(fileDefault).toBe(fileInline); 410 | }); 411 | 412 | test('minify w/ legalComments - eof', async () => { 413 | const built = await build( 414 | fixtures.legalComments, 415 | (config) => { 416 | configureEsbuildMinifyPlugin(config, { 417 | legalComments: 'eof', 418 | }); 419 | }, 420 | webpack, 421 | ); 422 | 423 | expect(built.stats.hasWarnings()).toBe(false); 424 | expect(built.stats.hasErrors()).toBe(false); 425 | 426 | const file = built.fs.readFileSync('/dist/index.js').toString(); 427 | expect(file.trim().endsWith('//! legal comment')).toBe(true); 428 | }); 429 | 430 | test('minify w/ legalComments - none', async () => { 431 | const built = await build( 432 | fixtures.legalComments, 433 | (config) => { 434 | configureEsbuildMinifyPlugin(config, { 435 | legalComments: 'none', 436 | }); 437 | }, 438 | webpack, 439 | ); 440 | 441 | expect(built.stats.hasWarnings()).toBe(false); 442 | expect(built.stats.hasErrors()).toBe(false); 443 | 444 | const file = built.fs.readFileSync('/dist/index.js', 'utf8'); 445 | expect(file).not.toMatch('//! legal comment'); 446 | }); 447 | 448 | test('minify w/ legalComments - external', async () => { 449 | const built = await build( 450 | fixtures.legalComments, 451 | (config) => { 452 | configureEsbuildMinifyPlugin(config, { 453 | legalComments: 'external', 454 | }); 455 | }, 456 | webpack, 457 | ); 458 | 459 | expect(built.stats.hasWarnings()).toBe(false); 460 | expect(built.stats.hasErrors()).toBe(false); 461 | 462 | expect(Object.keys(built.stats.compilation.assets)).toStrictEqual([ 463 | 'index.js', 464 | 'index.js.LEGAL.txt', 465 | ]); 466 | const file = built.fs.readFileSync('/dist/index.js', 'utf8'); 467 | expect(file).not.toMatch('//! legal comment'); 468 | 469 | const extracted = built.fs.readFileSync('/dist/index.js.LEGAL.txt', 'utf8'); 470 | expect(extracted).toMatch('//! legal comment'); 471 | }); 472 | }); 473 | }); 474 | 475 | describe('implementation', ({ test }) => { 476 | test('error', async () => { 477 | const runWithImplementation = async (implementation: EsbuildPluginOptions['implementation']) => { 478 | await build( 479 | fixtures.blank, 480 | (config) => { 481 | configureEsbuildMinifyPlugin(config, { 482 | implementation, 483 | }); 484 | }, 485 | webpack, 486 | ); 487 | }; 488 | 489 | await expect( 490 | // @ts-expect-error testing invalid type 491 | runWithImplementation({}), 492 | ).rejects.toThrow( 493 | '[EsbuildPlugin] implementation.transform must be an esbuild transform function. Received undefined', 494 | ); 495 | 496 | await expect( 497 | // @ts-expect-error testing invalid type 498 | runWithImplementation({ transform: 123 }), 499 | ).rejects.toThrow( 500 | '[EsbuildPlugin] implementation.transform must be an esbuild transform function. Received number', 501 | ); 502 | }); 503 | 504 | test('customizable', async () => { 505 | const code = 'export function foo() { return "CUSTOM_ESBUILD_IMPLEMENTATION"; }'; 506 | const built = await build( 507 | fixtures.blank, 508 | (config) => { 509 | configureEsbuildMinifyPlugin(config, { 510 | implementation: { 511 | transform: async () => ({ 512 | code, 513 | map: '', 514 | warnings: [], 515 | mangleCache: {}, 516 | legalComments: '', 517 | }), 518 | }, 519 | }); 520 | }, 521 | webpack, 522 | ); 523 | 524 | expect(built.stats.hasWarnings()).toBe(false); 525 | expect(built.stats.hasErrors()).toBe(false); 526 | expect( 527 | built.fs.readFileSync('/dist/index.js', 'utf8'), 528 | ).toBe(code); 529 | }); 530 | 531 | test('customize with real esbuild', async () => { 532 | const built = await build( 533 | fixtures.minification, 534 | (config) => { 535 | configureEsbuildMinifyPlugin(config, { 536 | implementation: esbuild, 537 | }); 538 | }, 539 | webpack, 540 | ); 541 | 542 | expect(built.stats.hasWarnings()).toBe(false); 543 | expect(built.stats.hasErrors()).toBe(false); 544 | 545 | const exportedFunction = built.require('/dist/'); 546 | expect(exportedFunction('hello world')).toBe('hello world'); 547 | assertMinified(exportedFunction.toString()); 548 | }); 549 | }); 550 | 551 | describe('CSS', ({ test }) => { 552 | test('minify CSS asset', async () => { 553 | const built = await build( 554 | fixtures.css, 555 | (config) => { 556 | configureEsbuildMinifyPlugin(config, { 557 | css: true, 558 | }); 559 | configureMiniCssExtractPlugin(config); 560 | }, 561 | webpack, 562 | ); 563 | 564 | expect(built.stats.hasWarnings()).toBe(false); 565 | expect(built.stats.hasErrors()).toBe(false); 566 | 567 | const file = built.fs.readFileSync('/dist/index.css').toString(); 568 | expect(file.trim()).not.toMatch(/\s{2,}/); 569 | }); 570 | 571 | test('exclude', async () => { 572 | const built = await build( 573 | fixtures.css, 574 | (config) => { 575 | configureEsbuildMinifyPlugin(config, { 576 | css: true, 577 | exclude: /index\.css$/, 578 | }); 579 | configureMiniCssExtractPlugin(config); 580 | }, 581 | webpack, 582 | ); 583 | 584 | expect(built.stats.hasWarnings()).toBe(false); 585 | expect(built.stats.hasErrors()).toBe(false); 586 | 587 | const file = built.fs.readFileSync('/dist/index.css').toString(); 588 | expect(file.trim()).toMatch(/\s{2,}/); 589 | }); 590 | 591 | test('minify w/ source-map', async () => { 592 | const built = await build( 593 | fixtures.css, 594 | (config) => { 595 | config.devtool = 'source-map'; 596 | configureEsbuildMinifyPlugin(config, { 597 | css: true, 598 | }); 599 | configureMiniCssExtractPlugin(config); 600 | }, 601 | webpack, 602 | ); 603 | 604 | expect(built.stats.hasWarnings()).toBe(false); 605 | expect(built.stats.hasErrors()).toBe(false); 606 | 607 | const cssFile = built.fs.readFileSync('/dist/index.css').toString(); 608 | const css = cssFile.trim().split('\n'); 609 | expect(css[0]).not.toMatch(/\s{2,}/); 610 | expect(css[2]).toMatch(/sourceMappingURL/); 611 | 612 | const sourcemapFile = built.fs.readFileSync('/dist/index.css.map', 'utf8'); 613 | expect(sourcemapFile).toMatch(/styles\.css/); 614 | }); 615 | }); 616 | 617 | test('supports Source without #sourceAndMap()', async () => { 618 | const createSource = (content: string) => ({ 619 | source: () => content, 620 | size: () => Buffer.byteLength(content), 621 | }) as webpack5.sources.Source; 622 | 623 | const built = await build(fixtures.blank, (config) => { 624 | configureEsbuildMinifyPlugin(config); 625 | 626 | config.plugins!.push({ 627 | apply: (compiler) => { 628 | compiler.hooks.compilation.tap('test', (compilation) => { 629 | compilation.hooks.processAssets.tap( 630 | { name: 'test' }, 631 | () => { 632 | compilation.emitAsset( 633 | 'test.js', 634 | createSource('console.log( 1 + 1)'), 635 | ); 636 | }, 637 | ); 638 | }); 639 | }, 640 | }); 641 | }, webpack5); 642 | 643 | expect(built.stats.hasWarnings()).toBe(false); 644 | expect(built.stats.hasErrors()).toBe(false); 645 | 646 | expect(Object.keys(built.stats.compilation.assets)).toStrictEqual([ 647 | 'index.js', 648 | 'test.js', 649 | ]); 650 | expect( 651 | built.fs.readFileSync('/dist/test.js', 'utf8'), 652 | ).toBe('console.log(2);\n'); 653 | }); 654 | 655 | describe('minify targets', ({ test }) => { 656 | test('no iife for node', async () => { 657 | const built = await build( 658 | fixtures.getHelpers, 659 | (config) => { 660 | configureEsbuildMinifyPlugin(config, { 661 | target: 'es2015', 662 | }); 663 | 664 | config.target = webpackIs4 ? 'node' : ['node']; 665 | delete config.output?.libraryTarget; 666 | delete config.output?.libraryExport; 667 | }, 668 | webpack, 669 | ); 670 | 671 | expect(built.stats.hasWarnings()).toBe(false); 672 | expect(built.stats.hasErrors()).toBe(false); 673 | 674 | const code = built.fs.readFileSync('/dist/index.js', 'utf8').toString(); 675 | expect(code.startsWith('var ')).toBe(true); 676 | }); 677 | 678 | test('no iife for web with high target (no helpers are added)', async () => { 679 | const built = await build( 680 | fixtures.getHelpers, 681 | (config) => { 682 | configureEsbuildMinifyPlugin(config); 683 | 684 | config.target = webpackIs4 ? 'web' : ['web']; 685 | delete config.output?.libraryTarget; 686 | delete config.output?.libraryExport; 687 | }, 688 | webpack, 689 | ); 690 | 691 | expect(built.stats.hasWarnings()).toBe(false); 692 | expect(built.stats.hasErrors()).toBe(false); 693 | 694 | const code = built.fs.readFileSync('/dist/index.js', 'utf8').toString(); 695 | expect(code.startsWith('(()=>{var ')).toBe(false); 696 | expect(countIife(code)).toBe(webpackIs4 ? 0 : 1); 697 | }); 698 | 699 | test('iife for web & low target', async () => { 700 | const built = await build( 701 | fixtures.getHelpers, 702 | (config) => { 703 | configureEsbuildMinifyPlugin(config, { 704 | target: 'es2015', 705 | }); 706 | 707 | config.target = webpackIs4 ? 'web' : ['web']; 708 | delete config.output?.libraryTarget; 709 | delete config.output?.libraryExport; 710 | }, 711 | webpack, 712 | ); 713 | 714 | expect(built.stats.hasWarnings()).toBe(false); 715 | expect(built.stats.hasErrors()).toBe(false); 716 | 717 | const code = built.fs.readFileSync('/dist/index.js', 'utf8').toString(); 718 | expect(code.startsWith('(()=>{var ')).toBe(true); 719 | expect(code.endsWith('})();\n')).toBe(true); 720 | expect(countIife(code)).toBe(webpackIs4 ? 1 : 2); 721 | }); 722 | }); 723 | 724 | test('supports webpack-merge', async () => { 725 | const built = await build( 726 | fixtures.minification, 727 | (config) => { 728 | configureEsbuildMinifyPlugin(config); 729 | const clonedConfig = merge({}, config); 730 | config.optimization = clonedConfig.optimization; 731 | }, 732 | webpack, 733 | ); 734 | 735 | expect(built.stats.hasWarnings()).toBe(false); 736 | expect(built.stats.hasErrors()).toBe(false); 737 | 738 | const exportedFunction = built.require('/dist/'); 739 | expect(exportedFunction('hello world')).toBe('hello world'); 740 | assertMinified(exportedFunction.toString()); 741 | }); 742 | 743 | // https://github.com/privatenumber/esbuild-loader/issues/356 744 | test('can handle empty modules set', async () => { 745 | await expect(build( 746 | fixtures.blank, 747 | (config) => { 748 | config.entry = 'not-there.js'; 749 | configureEsbuildMinifyPlugin(config); 750 | }, 751 | webpack, 752 | )).resolves.toBeTruthy(); 753 | }); 754 | 755 | test('multiple plugins', async () => { 756 | const built = await build( 757 | fixtures.define, 758 | (config) => { 759 | configureEsbuildMinifyPlugin(config); 760 | config.plugins?.push( 761 | new EsbuildPlugin({ 762 | define: { 763 | __TEST1__: '123', 764 | }, 765 | }), 766 | new EsbuildPlugin({ 767 | define: { 768 | __TEST2__: '321', 769 | }, 770 | }), 771 | ); 772 | }, 773 | webpack, 774 | ); 775 | 776 | expect(built.stats.hasWarnings()).toBe(false); 777 | expect(built.stats.hasErrors()).toBe(false); 778 | 779 | const exportedFunction = built.require('/dist/'); 780 | expect(exportedFunction('hello world')).toStrictEqual([123, 321]); 781 | }); 782 | }); 783 | }); 784 | -------------------------------------------------------------------------------- /tests/specs/tsconfig.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { createRequire } from 'node:module'; 3 | import { testSuite, expect } from 'manten'; 4 | import { createFixture } from 'fs-fixture'; 5 | import { execa } from 'execa'; 6 | import { tsconfigJson } from '../utils.js'; 7 | 8 | const require = createRequire(import.meta.url); 9 | 10 | const webpackCli = path.resolve('node_modules/webpack-cli/bin/cli.js'); 11 | const esbuildLoader = path.resolve('dist/index.cjs'); 12 | 13 | const detectStrictMode = '(function() { return !this; })()'; 14 | 15 | export default testSuite(({ describe }) => { 16 | describe('tsconfig', ({ describe }) => { 17 | describe('loader', ({ test }) => { 18 | test('finds tsconfig.json and applies strict mode', async () => { 19 | await using fixture = await createFixture({ 20 | src: { 21 | 'index.ts': `module.exports = [ 22 | ${detectStrictMode}, 23 | require("./not-strict.ts"), 24 | require("./different-config/strict.ts"), 25 | ];`, 26 | 'not-strict.ts': `module.exports = ${detectStrictMode}`, 27 | 'different-config': { 28 | 'strict.ts': `module.exports = ${detectStrictMode}`, 29 | 'tsconfig.json': tsconfigJson({ 30 | compilerOptions: { 31 | strict: true, 32 | }, 33 | }), 34 | }, 35 | }, 36 | 'webpack.config.js': ` 37 | module.exports = { 38 | mode: 'production', 39 | 40 | optimization: { 41 | minimize: false, 42 | }, 43 | 44 | resolveLoader: { 45 | alias: { 46 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, 47 | }, 48 | }, 49 | 50 | module: { 51 | rules: [{ 52 | test: /\\.ts$/, 53 | loader: 'esbuild-loader', 54 | }], 55 | }, 56 | 57 | entry: './src/index.ts', 58 | 59 | output: { 60 | libraryTarget: 'commonjs2', 61 | }, 62 | }; 63 | `, 64 | 'tsconfig.json': tsconfigJson({ 65 | compilerOptions: { 66 | strict: true, 67 | }, 68 | include: [ 69 | 'src/index.ts', 70 | ], 71 | }), 72 | }); 73 | 74 | await execa(webpackCli, { 75 | cwd: fixture.path, 76 | }); 77 | 78 | expect( 79 | require(path.join(fixture.path, 'dist/main.js')), 80 | ).toStrictEqual([true, false, true]); 81 | }); 82 | 83 | test('handles resource with query', async () => { 84 | await using fixture = await createFixture({ 85 | src: { 86 | 'index.ts': `module.exports = [${detectStrictMode}, require("./not-strict.ts?some-query")];`, 87 | 'not-strict.ts': `module.exports = ${detectStrictMode}`, 88 | }, 89 | 'webpack.config.js': ` 90 | module.exports = { 91 | mode: 'production', 92 | 93 | optimization: { 94 | minimize: false, 95 | }, 96 | 97 | resolveLoader: { 98 | alias: { 99 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, 100 | }, 101 | }, 102 | 103 | module: { 104 | rules: [{ 105 | test: /\\.ts$/, 106 | loader: 'esbuild-loader', 107 | }], 108 | }, 109 | 110 | entry: './src/index.ts', 111 | 112 | output: { 113 | libraryTarget: 'commonjs2', 114 | }, 115 | }; 116 | `, 117 | 'tsconfig.json': tsconfigJson({ 118 | compilerOptions: { 119 | strict: true, 120 | }, 121 | include: [ 122 | 'src/index.ts', 123 | ], 124 | }), 125 | }); 126 | 127 | await execa(webpackCli, { 128 | cwd: fixture.path, 129 | }); 130 | 131 | expect( 132 | require(path.join(fixture.path, 'dist/main.js')), 133 | ).toStrictEqual([true, false]); 134 | }); 135 | 136 | test('accepts custom tsconfig.json path', async () => { 137 | await using fixture = await createFixture({ 138 | src: { 139 | 'index.ts': `module.exports = [${detectStrictMode}, require("./strict.ts")];`, 140 | 'strict.ts': `module.exports = ${detectStrictMode}`, 141 | }, 142 | 'webpack.config.js': ` 143 | module.exports = { 144 | mode: 'production', 145 | 146 | optimization: { 147 | minimize: false, 148 | }, 149 | 150 | resolveLoader: { 151 | alias: { 152 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, 153 | }, 154 | }, 155 | 156 | module: { 157 | rules: [{ 158 | test: /\\.ts$/, 159 | loader: 'esbuild-loader', 160 | options: { 161 | tsconfig: './tsconfig.custom.json', 162 | } 163 | }], 164 | }, 165 | 166 | entry: './src/index.ts', 167 | 168 | output: { 169 | libraryTarget: 'commonjs2', 170 | }, 171 | }; 172 | `, 173 | 'tsconfig.custom.json': tsconfigJson({ 174 | compilerOptions: { 175 | strict: true, 176 | }, 177 | include: [ 178 | 'src/strict.ts', 179 | ], 180 | }), 181 | }); 182 | 183 | const { stdout } = await execa(webpackCli, { 184 | cwd: fixture.path, 185 | }); 186 | 187 | expect(stdout).toMatch('does not match its "include" patterns'); 188 | 189 | expect( 190 | require(path.join(fixture.path, 'dist/main.js')), 191 | ).toStrictEqual([true, true]); 192 | }); 193 | 194 | test('applies different tsconfig.json paths', async () => { 195 | await using fixture = await createFixture({ 196 | src: { 197 | 'index.ts': 'export class C { foo = 100; }', 198 | 'index2.ts': 'export class C { foo = 100; }', 199 | }, 200 | 'webpack.config.js': ` 201 | module.exports = { 202 | mode: 'production', 203 | 204 | optimization: { 205 | minimize: false, 206 | }, 207 | 208 | resolveLoader: { 209 | alias: { 210 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, 211 | }, 212 | }, 213 | 214 | module: { 215 | rules: [ 216 | { 217 | test: /index\\.ts$/, 218 | loader: 'esbuild-loader', 219 | options: { 220 | tsconfig: './tsconfig.custom1.json', 221 | } 222 | }, 223 | { 224 | test: /index2\\.ts$/, 225 | loader: 'esbuild-loader', 226 | options: { 227 | tsconfig: './tsconfig.custom2.json', 228 | } 229 | } 230 | ], 231 | }, 232 | 233 | entry: { 234 | index1: './src/index.ts', 235 | index2: './src/index2.ts', 236 | }, 237 | 238 | output: { 239 | libraryTarget: 'commonjs2', 240 | }, 241 | }; 242 | `, 243 | 'tsconfig.custom1.json': tsconfigJson({ 244 | compilerOptions: { 245 | useDefineForClassFields: false, 246 | }, 247 | }), 248 | 'tsconfig.custom2.json': tsconfigJson({ 249 | compilerOptions: { 250 | useDefineForClassFields: true, 251 | }, 252 | }), 253 | }); 254 | 255 | await execa(webpackCli, { 256 | cwd: fixture.path, 257 | }); 258 | 259 | const code1 = await fixture.readFile('dist/index1.js', 'utf8'); 260 | expect(code1).toMatch('this.foo = 100;'); 261 | 262 | const code2 = await fixture.readFile('dist/index2.js', 'utf8'); 263 | expect(code2).toMatch('__publicField(this, "foo", 100);'); 264 | }); 265 | 266 | test('fails on invalid tsconfig.json', async () => { 267 | await using fixture = await createFixture({ 268 | 'tsconfig.json': tsconfigJson({ 269 | extends: 'unresolvable-dep', 270 | }), 271 | src: { 272 | 'index.ts': ` 273 | console.log('Hello, world!' as numer); 274 | `, 275 | }, 276 | 'webpack.config.js': ` 277 | module.exports = { 278 | mode: 'production', 279 | 280 | optimization: { 281 | minimize: false, 282 | }, 283 | 284 | resolveLoader: { 285 | alias: { 286 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, 287 | }, 288 | }, 289 | 290 | resolve: { 291 | extensions: ['.ts', '.js'], 292 | }, 293 | 294 | module: { 295 | rules: [ 296 | { 297 | test: /.[tj]sx?$/, 298 | loader: 'esbuild-loader', 299 | options: { 300 | target: 'es2015', 301 | } 302 | } 303 | ], 304 | }, 305 | 306 | entry: { 307 | index: './src/index.ts', 308 | }, 309 | }; 310 | `, 311 | }); 312 | 313 | const { stdout, exitCode } = await execa(webpackCli, { 314 | cwd: fixture.path, 315 | reject: false, 316 | }); 317 | 318 | expect(stdout).toMatch('Error parsing tsconfig.json:\nFile \'unresolvable-dep\' not found.'); 319 | expect(exitCode).toBe(1); 320 | }); 321 | 322 | test('ignores invalid tsconfig.json in JS dependencies', async () => { 323 | await using fixture = await createFixture({ 324 | 'node_modules/fake-lib': { 325 | 'package.json': JSON.stringify({ 326 | name: 'fake-lib', 327 | }), 328 | 'tsconfig.json': tsconfigJson({ 329 | extends: 'unresolvable-dep', 330 | }), 331 | 'index.js': 'export function testFn() { return "Hi!" }', 332 | }, 333 | 'src/index.ts': ` 334 | import { testFn } from "fake-lib"; 335 | testFn(); 336 | `, 337 | 'webpack.config.js': ` 338 | module.exports = { 339 | mode: 'production', 340 | 341 | optimization: { 342 | minimize: false, 343 | }, 344 | 345 | resolveLoader: { 346 | alias: { 347 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, 348 | }, 349 | }, 350 | 351 | resolve: { 352 | extensions: ['.ts', '.js'], 353 | }, 354 | 355 | module: { 356 | rules: [ 357 | { 358 | test: /.[tj]sx?$/, 359 | loader: 'esbuild-loader', 360 | options: { 361 | target: 'es2015', 362 | } 363 | } 364 | ], 365 | }, 366 | 367 | entry: { 368 | index: './src/index.ts', 369 | }, 370 | }; 371 | `, 372 | }); 373 | 374 | const { stdout, exitCode } = await execa(webpackCli, { 375 | cwd: fixture.path, 376 | }); 377 | 378 | expect(stdout).not.toMatch('Error parsing tsconfig.json'); 379 | expect(exitCode).toBe(0); 380 | }); 381 | 382 | test('warns on invalid tsconfig.json in TS dependencies', async () => { 383 | await using fixture = await createFixture({ 384 | 'node_modules/fake-lib': { 385 | 'package.json': JSON.stringify({ 386 | name: 'fake-lib', 387 | }), 388 | 'tsconfig.json': tsconfigJson({ 389 | extends: 'unresolvable-dep', 390 | }), 391 | 'index.ts': 'export function testFn(): string { return "Hi!" }', 392 | }, 393 | 'src/index.ts': ` 394 | import { testFn } from "fake-lib"; 395 | testFn(); 396 | `, 397 | 'webpack.config.js': ` 398 | module.exports = { 399 | mode: 'production', 400 | 401 | optimization: { 402 | minimize: false, 403 | }, 404 | 405 | resolveLoader: { 406 | alias: { 407 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, 408 | }, 409 | }, 410 | 411 | resolve: { 412 | extensions: ['.ts', '.js'], 413 | }, 414 | 415 | module: { 416 | rules: [ 417 | { 418 | test: /.[tj]sx?$/, 419 | loader: 'esbuild-loader', 420 | options: { 421 | target: 'es2015', 422 | } 423 | } 424 | ], 425 | }, 426 | 427 | entry: { 428 | index: './src/index.ts', 429 | }, 430 | }; 431 | `, 432 | }); 433 | 434 | const { stdout, exitCode } = await execa(webpackCli, { 435 | cwd: fixture.path, 436 | }); 437 | 438 | expect(stdout).toMatch('Error parsing tsconfig.json:\nFile \'unresolvable-dep\' not found.'); 439 | 440 | // Warning so doesn't fail 441 | expect(exitCode).toBe(0); 442 | }); 443 | }); 444 | 445 | describe('plugin', ({ test }) => { 446 | /** 447 | * Since the plugin applies on distribution assets, it should not apply 448 | * any tsconfig settings. 449 | */ 450 | test('should not detect tsconfig.json and apply strict mode', async () => { 451 | await using fixture = await createFixture({ 452 | src: { 453 | 'index.js': 'console.log(1)', 454 | }, 455 | 'webpack.config.js': ` 456 | const { EsbuildPlugin } = require(${JSON.stringify(esbuildLoader)}); 457 | module.exports = { 458 | mode: 'production', 459 | optimization: { 460 | minimizer: [ 461 | new EsbuildPlugin(), 462 | ], 463 | }, 464 | entry: './src/index.js', 465 | }; 466 | `, 467 | 'tsconfig.json': tsconfigJson({ 468 | compilerOptions: { 469 | strict: true, 470 | }, 471 | }), 472 | }); 473 | 474 | await execa(webpackCli, { 475 | cwd: fixture.path, 476 | }); 477 | 478 | const code = await fixture.readFile('dist/main.js', 'utf8'); 479 | expect(code).not.toMatch('use strict'); 480 | }); 481 | }); 482 | }); 483 | }); 484 | -------------------------------------------------------------------------------- /tests/specs/webpack5.ts: -------------------------------------------------------------------------------- 1 | import { testSuite, expect } from 'manten'; 2 | import { build } from 'webpack-test-utils'; 3 | import webpack5 from 'webpack5'; 4 | import { configureEsbuildMinifyPlugin } from '../utils.js'; 5 | 6 | const { RawSource } = webpack5.sources; 7 | 8 | export default testSuite(({ describe }) => { 9 | describe('Webpack 5', ({ test }) => { 10 | test('Stats', async () => { 11 | const built = await build({ '/src/index.js': '' }, (config) => { 12 | configureEsbuildMinifyPlugin(config); 13 | }, webpack5); 14 | 15 | expect(built.stats.hasWarnings()).toBe(false); 16 | expect(built.stats.hasErrors()).toBe(false); 17 | expect(built.stats.toString().includes('[minimized]')).toBe(true); 18 | }); 19 | 20 | test('Minifies new assets', async () => { 21 | const built = await build({ '/src/index.js': '' }, (config) => { 22 | configureEsbuildMinifyPlugin(config); 23 | 24 | config.plugins!.push({ 25 | apply: (compiler) => { 26 | compiler.hooks.compilation.tap('test', (compilation) => { 27 | compilation.hooks.processAssets.tap( 28 | { name: 'test' }, 29 | () => { 30 | compilation.emitAsset( 31 | 'test.js', 32 | new RawSource('const value = 1;\n\nexport default value;'), 33 | ); 34 | }, 35 | ); 36 | }); 37 | }, 38 | }); 39 | }, webpack5); 40 | 41 | expect(built.stats.hasWarnings()).toBe(false); 42 | expect(built.stats.hasErrors()).toBe(false); 43 | 44 | const asset = built.stats.compilation.getAsset('test.js'); 45 | expect(asset!.info.minimized).toBe(true); 46 | 47 | const file = built.fs.readFileSync('/dist/test.js', 'utf8'); 48 | expect(file).toBe('const e=1;export default 1;\n'); 49 | }); 50 | 51 | test('Doesnt minify minimized assets', async () => { 52 | let sourceAndMapCalled = false; 53 | await build({ '/src/index.js': '' }, (config) => { 54 | configureEsbuildMinifyPlugin(config); 55 | 56 | config.plugins!.push({ 57 | apply: (compiler) => { 58 | compiler.hooks.compilation.tap('test', (compilation) => { 59 | compilation.hooks.processAssets.tap( 60 | { name: 'test' }, 61 | () => { 62 | const asset = new RawSource(''); 63 | 64 | // @ts-expect-error overwriting to make sure it's not called 65 | asset.sourceAndMap = () => { 66 | sourceAndMapCalled = true; 67 | }; 68 | 69 | compilation.emitAsset( 70 | 'test.js', 71 | asset, 72 | { minimized: true }, 73 | ); 74 | }, 75 | ); 76 | }); 77 | }, 78 | }); 79 | }, webpack5); 80 | 81 | expect(sourceAndMapCalled).toBe(false); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import type webpack4 from 'webpack'; 3 | import type webpack5 from 'webpack5'; 4 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 5 | import type { TsConfigJson } from 'get-tsconfig'; 6 | import { EsbuildPlugin, type EsbuildPluginOptions } from '#esbuild-loader'; 7 | 8 | const esbuildLoaderPath = path.resolve('./dist/index.cjs'); 9 | 10 | type Webpack4 = typeof webpack4; 11 | 12 | type Webpack5 = typeof webpack5; 13 | 14 | export type Webpack = Webpack4 & Webpack5; 15 | 16 | export type WebpackConfiguration = webpack4.Configuration | webpack5.Configuration; 17 | 18 | type RuleSetUseItem = webpack4.RuleSetUseItem & webpack5.RuleSetUseItem; 19 | 20 | type RuleSetRule = webpack4.RuleSetRule & webpack5.RuleSetRule; 21 | 22 | export const isWebpack4 = ( 23 | webpack: Webpack4 | Webpack5, 24 | ): webpack is Webpack4 => Boolean(webpack.version?.startsWith('4.')); 25 | 26 | export const configureEsbuildLoader = ( 27 | config: WebpackConfiguration, 28 | rulesConfig?: RuleSetRule, 29 | ) => { 30 | config.resolveLoader!.alias = { 31 | 'esbuild-loader': esbuildLoaderPath, 32 | }; 33 | 34 | config.module!.rules!.push({ 35 | test: /\.js$/, 36 | loader: 'esbuild-loader', 37 | ...rulesConfig, 38 | options: { 39 | tsconfigRaw: undefined, 40 | ...( 41 | typeof rulesConfig?.options === 'object' 42 | ? rulesConfig.options 43 | : {} 44 | ), 45 | }, 46 | }); 47 | }; 48 | 49 | export const configureEsbuildMinifyPlugin = ( 50 | config: WebpackConfiguration, 51 | options?: EsbuildPluginOptions, 52 | ) => { 53 | config.optimization = { 54 | minimize: true, 55 | minimizer: [ 56 | new EsbuildPlugin({ 57 | tsconfigRaw: undefined, 58 | ...options, 59 | }), 60 | ], 61 | }; 62 | }; 63 | 64 | export const configureCssLoader = ( 65 | config: WebpackConfiguration, 66 | ) => { 67 | const cssRule = { 68 | test: /\.css$/, 69 | use: [ 70 | 'css-loader', 71 | ] as RuleSetUseItem[], 72 | }; 73 | config.module!.rules!.push(cssRule); 74 | return cssRule; 75 | }; 76 | 77 | export const configureMiniCssExtractPlugin = ( 78 | config: WebpackConfiguration, 79 | ) => { 80 | const cssRule = configureCssLoader(config); 81 | cssRule.use.unshift(MiniCssExtractPlugin.loader); 82 | 83 | config.plugins!.push( 84 | // @ts-expect-error Forcing it to Webpack 5 85 | new MiniCssExtractPlugin(), 86 | ); 87 | }; 88 | 89 | export const tsconfigJson = ( 90 | tsconfigObject: TsConfigJson, 91 | ) => JSON.stringify(tsconfigObject); 92 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "lib": ["ESNext"], 5 | "moduleDetection": "force", 6 | 7 | "module": "preserve", 8 | "resolveJsonModule": true, 9 | "allowJs": true, 10 | "strict": true, 11 | // "noUncheckedIndexedAccess": true, 12 | "noImplicitOverride": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "verbatimModuleSyntax": true, 16 | "skipLibCheck": true, 17 | } 18 | } --------------------------------------------------------------------------------