Repository: egoist/esbuild-loader Branch: master Commit: 59fd00ac5bfb Files: 29 Total size: 103.7 KB Directory structure: gitextract_xlb_uscu/ ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.yml │ │ ├── config.yml │ │ └── feature-request.yml │ ├── renovate.json │ └── workflows/ │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode/ │ └── settings.json ├── LICENSE ├── README.md ├── package.json ├── 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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = tab end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.yml ================================================ name: 🐛 Bug report description: Run into a bug? File a report and get the help you need! labels: [bug, pending triage] body: - type: markdown attributes: value: | > 💁‍♂️ Keep in mind this is a collaborative effort. Please do your best to debug, communicate, and demonstrate the problem. ## 👀 Tell us about the bug A _clear and concise_ description of what the bug is. - type: textarea attributes: label: Problem description: Please refrain from describing anything other than the problem. placeholder: | What's the problem? Do you have an error stack trace? Do you have screenshots? validations: required: true - type: textarea attributes: label: Expected behavior placeholder: | What did you expect to happen? validations: required: true - type: markdown attributes: value: | ## 📋 Minimal reproduction > ⚠️ If a **minimal** reproduction is not provided, **the issue will be closed.** The minimal reproduction proves a bug exists in this project, allows others to debug it for you, and streamlines resolution.
How do I create a minimal reproduction? 1. Delete all unnecessary files and data. Keep it under 10 files. Delete irrelevant files (e.g. `LICENSE`, `.npmrc`, `.github`). Do you have unnecessary dependencies, scripts, properties in `package.json`? 2. Delete all unnecessary code. Reduce the scope. Try to narrow the scope of the reproduction as much as possible. Is it a frontend or backend problem? Delete the other. Ideally, the code is reduced to a few lines of code in a single file. 3. Set the `start` script in `package.json` to the command that demonstrates the bug. 4. Verify the reproduction. Try running it yourself, and check: - Is the problem immediately reproducible? - Are dependencies properly declared? - Could I find more files or code that isn't necessary? 5. Upload the reproduction to [StackBlitz](https://stackblitz.com), or a new GitHub repository, so it can be opened in the browser.
Starter template: [fork this template on StackBlitz](https://stackblitz.com/edit/node-guv65j?file=webpack.config.js&view=editor) (Delete everything unnecessary) The _smaller_ the reproduction, the _faster_ others can help you. - type: input attributes: label: Minimal reproduction URL placeholder: https://stackblitz.com/edit/... validations: required: true - type: markdown attributes: value: | > **🙋 Need help?** > > Get personalized help through my [_Priority Support_ service](https://github.com/sponsors/privatenumber). > From minimal reproduction creation to debugging, I'm happy to assist you! - type: markdown attributes: value: "## 🌍 Environment" - type: input attributes: label: Version placeholder: v0.0.0 validations: required: true - type: input attributes: label: Node.js version placeholder: v0.0.0 validations: required: true - type: dropdown id: package-manager attributes: label: Package manager options: - npm - yarn - pnpm - bun - N/A validations: required: true - type: dropdown attributes: label: Operating system options: - macOS - Windows - Linux validations: required: true - type: markdown attributes: value: | ## 🛠️ Contribute 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. - type: checkboxes attributes: label: Contributions options: - label: I plan to open a pull request for this issue - label: I plan to make a financial contribution to this project - type: markdown attributes: value: | ## 🚀 Need immediate attention? 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. [👉 Become a _Priority Patron_ now!](https://github.com/sponsors/privatenumber) ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 💬 Help / Questions / Discussions url: https://github.com/privatenumber/esbuild-loader/discussions about: Use GitHub Discussions for anything else - name: 🚀 Priority Support url: https://github.com/sponsors/privatenumber/ about: Need help? Get prioritized help for all your questions and issues ================================================ FILE: .github/ISSUE_TEMPLATE/feature-request.yml ================================================ name: 🌟 Feature request description: Have a great idea for this project? Tell us more! labels: [enhancement, pending triage] body: - type: markdown attributes: value: | > 💁‍♂️ Please remember others are volunteering to help you for free, and put in your best effort to follow this form. ## 👀 Tell us about your idea - type: textarea attributes: label: Feature request description: A clear and concise description of the feature. placeholder: | I would love to be able to... validations: required: true - type: textarea attributes: label: Motivations description: | Describe the problem you’re tackling with this feature request. placeholder: | How did you come across this idea? validations: required: true - type: textarea attributes: label: Alternatives description: | Have you considered alternative solutions? Is there a workaround? placeholder: | Do you have alternative proposals? Are there workarounds? - type: textarea attributes: label: Additional context description: | Anything else to share? Screenshots? Links? - type: markdown attributes: value: | > **🙋 Experiencing a challenging problem and need expert assistance?** > > Get personalized help through my [_Priority Support_ service](https://github.com/sponsors/privatenumber). From debugging to implementation, I'm here to assist you! - type: markdown attributes: value: | ## 🛠️ Contribute 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. - type: checkboxes attributes: label: Contributions options: - label: I plan to open a pull request for this issue - label: I plan to make a financial contribution to this project - type: markdown attributes: value: | ## 🚀 Need immediate attention? 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. [👉 Become a _Priority Patron_ now!](https://github.com/sponsors/privatenumber) ================================================ FILE: .github/renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "github>privatenumber/renovate-config" ], "packageRules": [ { "description": "Pin css-loader to v5 (v6+ dropped Webpack 4 support)", "matchPackageNames": [ "css-loader" ], "allowedVersions": "<6.0.0" }, { "description": "Pin mini-css-extract-plugin to v1 (v2+ dropped Webpack 4 support)", "matchPackageNames": [ "mini-css-extract-plugin" ], "allowedVersions": "<2.0.0" }, { "description": "Pin webpack-cli to v4 (v5+ dropped Webpack 4 support)", "matchPackageNames": [ "webpack-cli" ], "allowedVersions": "<5.0.0" } ] } ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: branches: [master, develop] permissions: contents: write issues: write pull-requests: write id-token: write jobs: release: name: Release if: ( github.repository_owner == 'pvtnbr' && github.ref_name =='develop' ) || ( github.repository_owner == 'privatenumber' && github.ref_name =='master' ) runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: token: ${{ secrets.GH_TOKEN }} - name: Use Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version-file: .nvmrc - name: Setup pnpm uses: pnpm/action-setup@v3 with: run_install: true - name: Prerelease to GitHub if: github.repository_owner == 'pvtnbr' run: | git remote add public https://github.com/$(echo $GITHUB_REPOSITORY | sed "s/^pvtnbr/privatenumber/") git fetch public master 'refs/tags/*:refs/tags/*' git push --force --tags origin refs/remotes/public/master:refs/heads/master jq ' .publishConfig.registry = "https://npm.pkg.github.com" | .name = ("@" + env.GITHUB_REPOSITORY_OWNER + "/" + .name) | .repository = env.GITHUB_REPOSITORY | .release.branches = [ "master", { name: "develop", prerelease: "rc", channel: "latest" } ] ' package.json > _package.json mv _package.json package.json - name: Release env: GH_TOKEN: ${{ secrets.GH_TOKEN }} run: pnpm dlx semantic-release ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: push: branches: [master, develop] pull_request: jobs: test: name: Test runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Use Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version-file: .nvmrc - name: Setup pnpm uses: pnpm/action-setup@v3 with: run_install: true - name: Lint run: pnpm lint - name: Type check run: pnpm type-check - name: Build run: pnpm build - name: Test run: pnpm test - name: Test Node.js v16 run: pnpm --use-node-version=16.19.0 tsx tests ================================================ FILE: .gitignore ================================================ # Mac OS X .DS_Store # Logs logs *.log npm-debug.log* # Dependency directories node_modules/ # Optional npm cache directory .npm # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Distribution files dist # Cache .eslintcache # Agent skills from npm packages (managed by skills-npm) **/skills/npm-* ================================================ FILE: .npmrc ================================================ shell-emulator=true ================================================ FILE: .nvmrc ================================================ v24.11.0 ================================================ FILE: .vscode/settings.json ================================================ { "typescript.tsdk": "node_modules/typescript/lib", } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) Hiroki Osame Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================


esbuild-loader

Speed up your Webpack build with [esbuild](https://github.com/evanw/esbuild)! 🔥 [_esbuild_](https://github.com/evanw/esbuild) is a JavaScript bundler written in Go that supports blazing fast ESNext & TypeScript transpilation and [JS minification](https://github.com/privatenumber/minification-benchmarks/). [_esbuild-loader_](https://github.com/privatenumber/esbuild-loader) lets you harness the speed of esbuild in your Webpack build by offering faster alternatives for transpilation (eg. `babel-loader`/`ts-loader`) and minification (eg. Terser)! > [!TIP] > **Are you using TypeScript with Node.js?** > > Supercharge your Node.js with TypeScript support using _tsx_! > > _tsx_ is a simple, lightweight, and blazing fast alternative to ts-node. > > [→ Learn more about _tsx_](https://github.com/privatenumber/tsx)

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

## 🚀 Install ```bash npm i -D esbuild-loader ``` ## 🚦 Quick Setup To leverage `esbuild-loader` in your Webpack configuration, add a new rule for `esbuild-loader` matching the files you want to transform, such as `.js`, `.jsx`, `.ts`, or `.tsx`. Make sure to remove any other loaders you were using before (e.g. `babel-loader`/`ts-loader`). Here's an example of how to set it up in your `webpack.config.js`: ```diff module.exports = { module: { rules: [ - // Transpile JavaScript - { - test: /\.js$/, - use: 'babel-loader' - }, - - // Compile TypeScript - { - test: /\.tsx?$/, - use: 'ts-loader' - }, + // Use esbuild to compile JavaScript & TypeScript + { + // Match `.js`, `.jsx`, `.ts` or `.tsx` files + test: /\.[jt]sx?$/, + loader: 'esbuild-loader', + options: { + // JavaScript version to compile to + target: 'es2015' + } + }, // Other rules... ], }, } ``` In this setup, esbuild will automatically determine how to handle each file based on its extension: - `.js` files will be treated as JS (no JSX allowed) - `.jsx` as JSX - `.ts` as TS (no TSX allowed) - `.tsx` as TSX If you want to force a specific loader on different file extensions (e.g. to allow JSX in `.js` files), you can use the [`loader` option](https://github.com/privatenumber/esbuild-loader/#loader): ```diff { test: /\.js$/, loader: 'esbuild-loader', options: { + // Treat `.js` files as `.jsx` files + loader: 'jsx', // JavaScript version to transpile to target: 'es2015' } } ``` ## Loader ### JavaScript `esbuild-loader` can be used in-place of `babel-loader` to transpile new JavaScript syntax into code compatible with older JavaScript engines. While this ensures your code can run smoothly across various environments, note that it can bloat your output code (like Babel). The default target is `esnext`, which means it doesn't perform any transpilations. To specify a target JavaScript engine that only supports ES2015, use the following configuration in your `webpack.config.js`: ```diff { test: /\.jsx?$/, loader: 'esbuild-loader', options: { + target: 'es2015', }, } ``` For a detailed list of supported transpilations and versions, refer to [the esbuild documentation](https://esbuild.github.io/content-types/#javascript). ### TypeScript `esbuild-loader` can be used in-place of `ts-loader` to compile TypeScript. ```js ({ // `.ts` or `.tsx` files test: /\.tsx?$/, loader: 'esbuild-loader' }) ``` > [!IMPORTANT] > It's possible to use `loader: 'tsx'` for both `.ts` and `.tsx` files, but this could lead to unexpected behavior as TypeScript and TSX do not have compatible syntaxes. > > [→ Read more](https://esbuild.github.io/content-types/#ts-vs-tsx) #### `tsconfig.json` If you have a `tsconfig.json` file in your project, `esbuild-loader` will automatically load it. If it's under a custom name, you can pass in the path via `tsconfig` option: ```diff { test: /\.tsx?$/, loader: 'esbuild-loader', options: { + tsconfig: './tsconfig.custom.json', }, }, ``` > Behind the scenes: [`get-tsconfig`](https://github.com/privatenumber/get-tsconfig) is used to load the tsconfig, and to also resolve the `extends` property if it exists. The `tsconfigRaw` option can be used to pass in a raw `tsconfig` object, but it will not resolve the `extends` property. ##### Caveats - esbuild only supports a subset of `tsconfig` options [(see `TransformOptions` interface)](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L159-L165). - Enable [`isolatedModules`](https://www.typescriptlang.org/tsconfig#isolatedModules) to avoid mis-compilation with features like re-exporting types. - Enable [`esModuleInterop`](https://www.typescriptlang.org/tsconfig/#esModuleInterop) to make TypeScript's type system compatible with ESM imports. - Features that require type interpretation, such as `emitDecoratorMetadata` and declaration, are not supported. [→ Read more about TypeScript Caveats](https://esbuild.github.io/content-types/#typescript-caveats) #### `tsconfig.json` Paths Use [tsconfig-paths-webpack-plugin](https://github.com/dividab/tsconfig-paths-webpack-plugin) to add support for [`tsconfig.json#paths`](https://www.typescriptlang.org/tsconfig/paths.html). Since `esbuild-loader` only transforms code, it cannot aid Webpack with resolving paths. #### Type-checking esbuild **does not** type check your code. And according to the [esbuild FAQ](https://esbuild.github.io/faq/#:~:text=typescript%20type%20checking%20(just%20run%20tsc%20separately)), it will not be supported. Consider these type-checking alternatives: - Using an IDEs like [VSCode](https://code.visualstudio.com/docs/languages/typescript) or [WebStorm](https://www.jetbrains.com/help/webstorm/typescript-support.html) that has live type-checking built in - Running `tsc --noEmit` to type check - Integrating type-checking to your Webpack build as a separate process using [`fork-ts-checker-webpack-plugin`](https://github.com/TypeStrong/fork-ts-checker-webpack-plugin) ### Defining constants Use the [`define`](#define) option to replace global identifiers with constant expressions: ```diff { test: /\.[jt]sx?$/, loader: 'esbuild-loader', options: { + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, }, } ``` > [!TIP] > The loader's `define` works with **all devtools**, including eval-based ones. If you're using the [plugin's `define`](#defining-constants-1) and it's not working, try the loader instead. ## EsbuildPlugin ### Minification Esbuild supports JavaScript minification, offering a faster alternative to traditional JS minifiers like Terser or UglifyJs. Minification is crucial for reducing file size and improving load times in web development. For a comparative analysis of its performance, refer to these [minification benchmarks](https://github.com/privatenumber/minification-benchmarks). In `webpack.config.js`: ```diff + const { EsbuildPlugin } = require('esbuild-loader') module.exports = { ..., + optimization: { + minimizer: [ + new EsbuildPlugin({ + target: 'es2015' // Syntax to transpile to (see options below for possible values) + }) + ] + }, } ``` > [!TIP] > Utilizing the `target` option allows for the use of newer JavaScript syntax, enhancing minification effectiveness. ### Defining constants Webpack's [`DefinePlugin`](https://webpack.js.org/plugins/define-plugin/) can replaced with `EsbuildPlugin` to define global constants. This could speed up the build by removing the parsing costs associated with the `DefinePlugin`. In `webpack.config.js`: ```diff - const { DefinePlugin } = require('webpack') + const { EsbuildPlugin } = require('esbuild-loader') module.exports = { // ..., plugins:[ - new DefinePlugin({ - 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), - }) + new EsbuildPlugin({ + define: { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), + }, + }), ] } ``` > [!WARNING] > The plugin's `define` option **does not work with eval-based devtools** (e.g., `eval`, `eval-source-map`). This is because eval devtools wrap module code in `eval()` strings, and esbuild's define cannot replace identifiers inside string literals. If you need to use `define` with eval devtools, use the [loader's `define` option](#define) instead, which transforms files before bundling. ### Transpilation If your project does not use TypeScript, JSX, or any other syntax that requires additional configuration beyond what Webpack provides, you can use `EsbuildPlugin` for transpilation instead of the loader. It will be faster because there's fewer files to process, and will produce a smaller output because polyfills will only be added once for the entire build as opposed to per file. To utilize esbuild for transpilation, simply set the `target` option on the plugin to specify which syntax support you want. ## CSS Minification Depending on your setup, there are two ways to minify CSS. You should already have CSS loading setup using [`css-loader`](https://github.com/webpack-contrib/css-loader). ### CSS assets If the CSS is extracted and emitted as `.css` file, you can replace CSS minification plugins like [`css-minimizer-webpack-plugin`](https://github.com/webpack-contrib/css-minimizer-webpack-plugin) with the `EsbuildPlugin`. Assuming the CSS is extracted using something like [MiniCssExtractPlugin](https://github.com/webpack-contrib/mini-css-extract-plugin), in `webpack.config.js`: ```diff const { EsbuildPlugin } = require('esbuild-loader') const MiniCssExtractPlugin = require('mini-css-extract-plugin'); module.exports = { // ..., optimization: { minimizer: [ new EsbuildPlugin({ target: 'es2015', + css: true // Apply minification to CSS assets }) ] }, module: { rules: [ { test: /\.css$/i, use: [ MiniCssExtractPlugin.loader, 'css-loader' ] } ], }, plugins: [ new MiniCssExtractPlugin() ] } ``` ### CSS in JS If your CSS is not emitted as a `.css` file, but rather injected with JavaScript using something like [`style-loader`](https://github.com/webpack-contrib/style-loader), you can use the loader for minification. In `webpack.config.js`: ```diff module.exports = { // ..., module: { rules: [ { test: /\.css$/i, use: [ 'style-loader', 'css-loader', + { + loader: 'esbuild-loader', + options: { + minify: true, + }, + }, ], }, ], }, } ``` ## Bring your own esbuild (Advanced) esbuild-loader comes with a version of esbuild it has been tested to work with. However, [esbuild has a frequent release cadence](https://github.com/evanw/esbuild/releases), and while we try to keep up with the important releases, it can get outdated. To work around this, you can use the `implementation` option in the loader or the plugin to pass in your own version of esbuild (eg. a newer one). > [!WARNING] > ⚠esbuild is not stable yet and can have dramatic differences across releases. Using a different version of esbuild is not guaranteed to work. ```diff + const esbuild = require('esbuild') module.exports = { // ..., module: { rules: [ { test: ..., loader: 'esbuild-loader', options: { // ..., + implementation: esbuild, }, }, ], }, } ``` ## Setup examples If you'd like to see working Webpack builds that use esbuild-loader for basic JS, React, TypeScript, Next.js, etc. check out the examples repo: [→ esbuild-loader examples](https://github.com/privatenumber/esbuild-loader-examples) ## ⚙️ Options ### Loader The loader supports [all Transform options from esbuild](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L158-L172). Note: - Source-maps are automatically configured for you via [`devtool`](https://webpack.js.org/configuration/devtool/). `sourcemap`/`sourcefile` options are ignored. - The root `tsconfig.json` is automatically detected for you. You don't need to pass in [`tsconfigRaw`](https://esbuild.github.io/api/#tsconfig-raw) unless it's in a different path. Here are some common configurations and custom options: #### tsconfig Type: `string` Pass in the file path to a **custom** tsconfig file. If the file name is `tsconfig.json`, it will automatically detect it. #### target Type: `string | Array` Default: `'es2015'` The target environment (e.g. `es2016`, `chrome80`, `esnext`). Read more about it in the [esbuild docs](https://esbuild.github.io/api/#target). #### loader Type: `'js' | 'jsx' | 'ts' | 'tsx' | 'css' | 'json' | 'text' | 'base64' | 'file' | 'dataurl' | 'binary' | 'default'` Default: `'default'` The loader to use to handle the file. See the type for [possible values](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L3). By default, it automatically detects the loader based on the file extension. Read more about it in the [esbuild docs](https://esbuild.github.io/api/#loader). #### jsxFactory Type: `string` Default: `React.createElement` Customize the JSX factory function name to use. Read more about it in the [esbuild docs](https://esbuild.github.io/api/#jsx-factory). #### jsxFragment Type: `string` Default: `React.Fragment` Customize the JSX fragment function name to use. Read more about it in the [esbuild docs](https://esbuild.github.io/api/#jsx-fragment). #### define Type: `{ [key: string]: string }` Replace global identifiers with constant expressions (e.g., `'process.env.NODE_ENV': JSON.stringify('production')`). > [!TIP] > Unlike the plugin's `define` option, the loader's `define` works with **all devtools** including eval-based ones (e.g., `eval-source-map`). This is because the loader transforms files _before_ Webpack bundles them, so identifiers are replaced before any eval wrapping occurs. Read more about it in the [esbuild docs](https://esbuild.github.io/api/#define). #### implementation Type: `{ transform: Function }` _Custom esbuild-loader option._ Use it to pass in a [different esbuild version](#bring-your-own-esbuild-advanced). ### EsbuildPlugin The loader supports [all Transform options from esbuild](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L158-L172). #### target Type: `string | Array` Default: `'esnext'` Target environment (e.g. `'es2016'`, `['chrome80', 'esnext']`) Read more about it in the [esbuild docs](https://esbuild.github.io/api/#target). Here are some common configurations and custom options: #### format Type: `'iife' | 'cjs' | 'esm'` Default: - `iife` if both of these conditions are met: - Webpack's [`target`](https://webpack.js.org/configuration/target/) is set to `web` - esbuild's [`target`](#target-1) is not `esnext` - `undefined` (no format conversion) otherwise The default is `iife` when esbuild is configured to support a low target, because esbuild injects helper functions at the top of the code. On the web, having functions declared at the top of a script can pollute the global scope. In some cases, this can lead to a variable collision error. By setting `format: 'iife'`, esbuild wraps the helper functions in an [IIFE](https://developer.mozilla.org/en-US/docs/Glossary/IIFE) to prevent them from polluting the global. Read more about it in the [esbuild docs](https://esbuild.github.io/api/#format). #### minify Type: `boolean` Default: `true` Enable JS minification. Enables all `minify*` flags below. To have nuanced control over minification, disable this and enable the specific minification you want below. Read more about it in the [esbuild docs](https://esbuild.github.io/api/#minify). #### minifyWhitespace Type: `boolean` Minify JS by removing whitespace. #### minifyIdentifiers Type: `boolean` Minify JS by shortening identifiers. #### minifySyntax Type: `boolean` Minify JS using equivalent but shorter syntax. #### legalComments Type: `'none' | 'inline' | 'eof' | 'external'` Default: `'inline'` Read more about it in the [esbuild docs](https://esbuild.github.io/api/#legal-comments). #### css Type: `boolean` Default: `false` Whether to minify CSS files. #### include Type: `string | RegExp | Array` To only apply the plugin to certain assets, pass in filters include #### exclude Type: `string | RegExp | Array` To prevent the plugin from applying to certain assets, pass in filters to exclude #### implementation Type: `{ transform: Function }` Use it to pass in a [different esbuild version](#bring-your-own-esbuild-advanced). ## 💡 Support For personalized assistance, take advantage of my [_Priority Support_ service](https://github.com/sponsors/privatenumber). Whether it's about Webpack configuration, esbuild, or TypeScript, I'm here to guide you every step of the way! ## 🙋‍♀️ FAQ ### Is it possible to use esbuild plugins? No. esbuild plugins are [only available in the build API](https://esbuild.github.io/plugins/#:~:text=plugins%20can%20also%20only%20be%20used%20with%20the%20build%20api%2C%20not%20with%20the%20transform%20api.). And esbuild-loader uses the transform API instead of the build API for two reasons: 1. The build API is for creating JS bundles, which is what Webpack does. If you want to use esbuild's build API, consider using esbuild directly instead of Webpack. 2. The build API reads directly from the file-system, but Webpack loaders operate in-memory. Webpack loaders are essentially just functions that are called with the source-code as the input. Not reading from the file-system allows loaders to be chainable. For example, using `vue-loader` to compile Single File Components (`.vue` files), then using `esbuild-loader` to transpile just the JS part of the SFC. ### Is it possible to use esbuild's [inject](https://esbuild.github.io/api/#inject) option? No. The `inject` option is only available in the build API. And esbuild-loader uses the transform API. However, you can use the Webpack equivalent [ProvidePlugin](https://webpack.js.org/plugins/provide-plugin/) instead. If you're using React, check out [this example](https://github.com/privatenumber/esbuild-loader-examples/blob/52ca91b8cb2080de5fc63cc6e9371abfefe1f823/examples/react/webpack.config.js#L39-L41) on how to auto-import React in your components. ### Is it possible to use Babel plugins? No. If you really need them, consider porting them over to a Webpack loader. And please don't chain `babel-loader` and `esbuild-loader`. The speed gains come from replacing `babel-loader`. ### Why am I not getting a [100x speed improvement](https://esbuild.github.io/faq/#benchmark-details) as advertised? Running esbuild as a standalone bundler vs esbuild-loader + Webpack are completely different: - esbuild is highly optimized, written in Go, and compiled to native code. Read more about it [here](https://esbuild.github.io/faq/#why-is-esbuild-fast). - esbuild-loader is handled by Webpack in a JS runtime, which applies esbuild transforms per file. On top of that, there's likely other loaders & plugins in a Webpack config that slow it down. Using a JS runtime introduces a bottleneck that makes reaching those speeds impossible. However, esbuild-loader can still speed up your build by removing the bottlenecks created by [`babel-loader`](https://twitter.com/wSokra/status/1316274855042584577), `ts-loader`, Terser, etc. ## 💞 Related projects #### [tsx](https://github.com/esbuild-kit/tsx) Node.js enhanced with esbuild to run TypeScript and ESM. #### [instant-mocha](https://github.com/privatenumber/instant-mocha) Webpack-integrated Mocha test-runner with Webpack 5 support. #### [webpack-localize-assets-plugin](https://github.com/privatenumber/webpack-localize-assets-plugin) Localize/i18nalize your Webpack build. Optimized for multiple locales! ## Sponsors

================================================ FILE: package.json ================================================ { "name": "esbuild-loader", "version": "0.0.0-semantic-release", "description": "⚡️ Speed up your Webpack build with esbuild", "keywords": [ "esbuild", "webpack", "loader", "typescript", "esnext" ], "license": "MIT", "repository": "privatenumber/esbuild-loader", "funding": "https://github.com/privatenumber/esbuild-loader?sponsor=1", "author": { "name": "Hiroki Osame", "email": "hiroki.osame@gmail.com" }, "files": [ "dist" ], "type": "module", "main": "./dist/index.cjs", "types": "./dist/index.d.cts", "exports": { ".": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }, "./package.json": "./package.json" }, "imports": { "#esbuild-loader": { "types": "./src/index.d.ts", "development": "./src/index.ts", "default": "./dist/index.cjs" } }, "packageManager": "pnpm@10.26.1", "scripts": { "build": "pkgroll --target=node16.19.0", "test": "tsx --env-file=.env tests", "dev": "tsx watch --env-file=.env --conditions=development tests", "lint": "lintroll --cache .", "type-check": "tsc --noEmit", "prepack": "pnpm build && clean-pkg-json", "prepare": "skills-npm" }, "dependencies": { "esbuild": "^0.27.1", "get-tsconfig": "^4.10.1", "loader-utils": "^2.0.4", "webpack-sources": "^3.3.4" }, "peerDependencies": { "webpack": "^4.40.0 || ^5.0.0" }, "devDependencies": { "@types/loader-utils": "^2.0.6", "@types/mini-css-extract-plugin": "2.4.0", "@types/node": "^22.19.3", "@types/webpack": "^4.41.40", "@types/webpack-sources": "^3.2.3", "clean-pkg-json": "^1.3.0", "css-loader": "^5.2.7", "execa": "^8.0.1", "fs-fixture": "^2.11.0", "lintroll": "^1.20.1", "manten": "^2.0.0", "memfs": "^4.56.11", "mini-css-extract-plugin": "^1.6.2", "pkgroll": "^2.17.0", "skills-npm": "^1.0.0", "tsx": "^4.20.6", "typescript": "^5.9.3", "webpack": "^4.47.0", "webpack-cli": "^4.10.0", "webpack-merge": "^6.0.1", "webpack-test-utils": "^2.1.0", "webpack5": "npm:webpack@^5.0.0" }, "pnpm": { "overrides": { "fsevents@1": "^2.0.0" } } } ================================================ FILE: src/@types/webpack-module-filename-helpers.d.ts ================================================ declare module 'webpack/lib/ModuleFilenameHelpers.js' { type Filter = string | RegExp; type FilterObject = { test?: Filter | Filter[]; include?: Filter | Filter[]; exclude?: Filter | Filter[]; }; export const matchObject: (filterObject: FilterObject, stringToCheck: string) => boolean; } ================================================ FILE: src/@types/webpack.d.ts ================================================ import 'webpack'; import type { LoaderContext as Webpack5LoaderContext } from 'webpack5'; declare module 'webpack' { namespace compilation { interface Compilation { getAssets(): Asset[]; emitAsset( file: string, source: Source, assetInfo?: AssetInfo, ): void; } } namespace loader { interface LoaderContext { getOptions: Webpack5LoaderContext['getOptions']; } } interface AssetInfo { minimized?: boolean; } } ================================================ FILE: src/index.d.ts ================================================ import type { EsbuildPluginOptions } from './types.js'; export class EsbuildPlugin { constructor(options?: EsbuildPluginOptions); apply(): void; } export * from './types.js'; ================================================ FILE: src/index.ts ================================================ import esbuildLoader from './loader.js'; import EsbuildPlugin from './plugin.js'; export default esbuildLoader; export { EsbuildPlugin }; ================================================ FILE: src/loader.ts ================================================ import path from 'path'; import { transform as defaultEsbuildTransform, type TransformOptions, } from 'esbuild'; import { getOptions } from 'loader-utils'; import type webpack from 'webpack'; import { getTsconfig, parseTsconfig, type TsConfigResult, } from 'get-tsconfig'; import type { LoaderOptions } from './types.js'; const tsconfigCache = new Map(); const tsExtensionsPattern = /\.(?:[cm]?ts|[tj]sx)$/; async function ESBuildLoader( this: webpack.loader.LoaderContext, source: string, ): Promise { const done = this.async()!; const options: LoaderOptions = typeof this.getOptions === 'function' ? this.getOptions() : getOptions(this); const { implementation, tsconfig: tsconfigPath, ...esbuildTransformOptions } = options; if (implementation && typeof implementation.transform !== 'function') { done( new TypeError( `esbuild-loader: options.implementation.transform must be an ESBuild transform function. Received ${typeof implementation.transform}`, ), ); return; } const transform = implementation?.transform ?? defaultEsbuildTransform; const { resourcePath } = this; const transformOptions = { ...esbuildTransformOptions, target: options.target ?? 'es2015', loader: options.loader ?? 'default', sourcemap: this.sourceMap, sourcefile: resourcePath, }; const isDependency = resourcePath.includes(`${path.sep}node_modules${path.sep}`); if ( !('tsconfigRaw' in transformOptions) // If file is local project, always try to apply tsconfig.json (e.g. allowJs) // If file is dependency, only apply tsconfig.json if .ts && (!isDependency || tsExtensionsPattern.test(resourcePath)) ) { /** * If a tsconfig.json path is specified, force apply it * Same way a provided tsconfigRaw is applied regardless * of whether it actually matches * * This follows TypeScript behavior: a tsconfig applies to all * files imported by entry files, not just files matching include patterns. * The include/files patterns only determine entry points. */ if (!isDependency && tsconfigPath) { const tsconfigFullPath = path.resolve(tsconfigPath); const cacheKey = `esbuild-loader:${tsconfigFullPath}`; let tsconfig = tsconfigCache.get(cacheKey); if (!tsconfig) { tsconfig = { config: parseTsconfig(tsconfigFullPath), path: tsconfigFullPath, }; tsconfigCache.set(cacheKey, tsconfig); } transformOptions.tsconfigRaw = tsconfig.config as TransformOptions['tsconfigRaw']; } else { /* Detect tsconfig file */ let tsconfig; try { // Webpack shouldn't be loading the same path multiple times so doesn't need to be cached tsconfig = getTsconfig(resourcePath, 'tsconfig.json'); } catch (error) { if (error instanceof Error) { const tsconfigError = new Error(`[esbuild-loader] Error parsing tsconfig.json:\n${error.message}`); if (isDependency) { this.emitWarning(tsconfigError); } else { return done(tsconfigError); } } } if (tsconfig) { transformOptions.tsconfigRaw = tsconfig.config as TransformOptions['tsconfigRaw']; } } } /** * Enable dynamic import by default to support code splitting in Webpack */ transformOptions.supported = { 'dynamic-import': true, ...transformOptions.supported, }; try { const { code, map } = await transform(source, transformOptions); done(null, code, map && JSON.parse(map)); } catch (error: unknown) { done(error as Error); } } export default ESBuildLoader; ================================================ FILE: src/plugin.ts ================================================ import { transform as defaultEsbuildTransform } from 'esbuild'; import { RawSource as WP4RawSource, SourceMapSource as WP4SourceMapSource, } from 'webpack-sources'; import type webpack4 from 'webpack'; import type webpack5 from 'webpack5'; import ModuleFilenameHelpers from 'webpack/lib/ModuleFilenameHelpers.js'; import { version } from '../package.json'; import type { EsbuildPluginOptions } from './types.js'; type Compiler = webpack4.Compiler | webpack5.Compiler; type Compilation = webpack4.compilation.Compilation | webpack5.Compilation; type Asset = webpack4.compilation.Asset | Readonly; type EsbuildTransform = typeof defaultEsbuildTransform; const isJsFile = /\.[cm]?js(?:\?.*)?$/i; const isCssFile = /\.css(?:\?.*)?$/i; const pluginName = 'EsbuildPlugin'; const transformAssets = async ( options: EsbuildPluginOptions, transform: EsbuildTransform, compilation: Compilation, useSourceMap: boolean, ) => { const { compiler } = compilation; const sources = 'webpack' in compiler && compiler.webpack.sources; const SourceMapSource = (sources ? sources.SourceMapSource : WP4SourceMapSource); const RawSource = (sources ? sources.RawSource : WP4RawSource); const { css: minifyCss, include, exclude, implementation, ...transformOptions } = options; const minimized = ( transformOptions.minify || transformOptions.minifyWhitespace || transformOptions.minifyIdentifiers || transformOptions.minifySyntax ); const assets = (compilation.getAssets() as Asset[]).filter(asset => ( // Filter out already minimized !asset.info.minimized // Filter out by file type && ( isJsFile.test(asset.name) || (minifyCss && isCssFile.test(asset.name)) ) && ModuleFilenameHelpers.matchObject( { include, exclude, }, asset.name, ) )); await Promise.all(assets.map(async (asset) => { const assetIsCss = isCssFile.test(asset.name); let source: string | Buffer | ArrayBuffer; let map = null; if (useSourceMap) { if (asset.source.sourceAndMap) { const sourceAndMap = asset.source.sourceAndMap(); source = sourceAndMap.source; map = sourceAndMap.map; } else { source = asset.source.source(); if (asset.source.map) { map = asset.source.map(); } } } else { source = asset.source.source(); } const sourceAsString = source.toString(); const result = await transform(sourceAsString, { ...transformOptions, loader: ( assetIsCss ? 'css' : transformOptions.loader ), sourcemap: useSourceMap, sourcefile: asset.name, }); if (result.legalComments) { compilation.emitAsset( `${asset.name}.LEGAL.txt`, new RawSource(result.legalComments) as webpack5.sources.Source, ); } compilation.updateAsset( asset.name, ( // @ts-expect-error complex webpack union type for source result.map ? new SourceMapSource( result.code, asset.name, result.map, sourceAsString, // @ts-expect-error webpack types use Object, not RawSourceMap map ?? undefined, true, ) : new RawSource(result.code) ), { ...asset.info, minimized, }, ); })); }; export default class EsbuildPlugin { options: EsbuildPluginOptions; constructor( options: EsbuildPluginOptions = {}, ) { const { implementation } = options; if ( implementation && typeof implementation.transform !== 'function' ) { throw new TypeError( `[${pluginName}] implementation.transform must be an esbuild transform function. Received ${typeof implementation.transform}`, ); } this.options = options; } apply(compiler: Compiler) { const { implementation, ...options } = this.options; const transform = implementation?.transform ?? defaultEsbuildTransform; if (!('format' in options)) { const { target } = compiler.options; const isWebTarget = ( Array.isArray(target) ? target.includes('web') : target === 'web' ); const wontGenerateHelpers = !options.target || ( Array.isArray(options.target) ? ( options.target.length === 1 && options.target[0] === 'esnext' ) : options.target === 'esnext' ); if (isWebTarget && !wontGenerateHelpers) { options.format = 'iife'; } } /** * Enable minification by default if used in the minimizer array * unless further specified in the options */ const usedAsMinimizer = compiler.options.optimization?.minimizer?.includes?.(this); if ( usedAsMinimizer && !( 'minify' in options || 'minifyWhitespace' in options || 'minifyIdentifiers' in options || 'minifySyntax' in options ) ) { options.minify = compiler.options.optimization?.minimize; } /** * Warn if `define` is used with eval-based devtools. * Eval devtools wrap module code in eval() strings, which prevents * esbuild's define from replacing identifiers (they become string content). */ const { devtool } = compiler.options; const isEvalDevtool = ( typeof devtool === 'string' && devtool.includes('eval') ); let hasWarnedEvalDefine = false; compiler.hooks.compilation.tap(pluginName, (compilation) => { if ( !hasWarnedEvalDefine && options.define && Object.keys(options.define).length > 0 && isEvalDevtool ) { hasWarnedEvalDefine = true; const warning = new Error( `[${pluginName}] The "define" option may not work as expected with eval-based devtools (current: "${devtool}"). ` + 'Eval devtools wrap module code in eval() strings, preventing identifier replacement. ' + 'Consider using the loader\'s "define" option instead, or switch to a non-eval devtool like "source-map".', ); compilation.warnings.push(warning as webpack5.WebpackError); } const meta = JSON.stringify({ name: 'esbuild-loader', version, options, }); compilation.hooks.chunkHash.tap( pluginName, (_, hash) => hash.update(meta), ); /** * Check if sourcemaps are enabled * Webpack 4: https://github.com/webpack/webpack/blob/v4.46.0/lib/SourceMapDevToolModuleOptionsPlugin.js#L20 * Webpack 5: https://github.com/webpack/webpack/blob/v5.75.0/lib/SourceMapDevToolModuleOptionsPlugin.js#LL27 */ let useSourceMap = false; /** * `finishModules` hook is called after all the `buildModule` hooks are called, * which is where the `useSourceMap` flag is set * https://webpack.js.org/api/compilation-hooks/#finishmodules */ compilation.hooks.finishModules.tap( pluginName, (modules) => { const firstModule = ( Array.isArray(modules) ? modules[0] : (modules as Set).values().next().value as webpack5.Module ); if (firstModule) { useSourceMap = firstModule.useSourceMap; } }, ); // Webpack 5 if ('processAssets' in compilation.hooks) { compilation.hooks.processAssets.tapPromise( { name: pluginName, // @ts-expect-error undefined on Function type stage: compilation.constructor.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE, additionalAssets: true, }, () => transformAssets(options, transform, compilation, useSourceMap), ); compilation.hooks.statsPrinter.tap(pluginName, (statsPrinter) => { statsPrinter.hooks.print .for('asset.info.minimized') .tap( pluginName, ( minimized, { green, formatFlag }, // @ts-expect-error type incorrectly doesn't accept undefined ) => ( minimized // @ts-expect-error type incorrectly doesn't accept undefined ? green(formatFlag('minimized')) : undefined ), ); }); } else { compilation.hooks.optimizeChunkAssets.tapPromise( pluginName, () => transformAssets(options, transform, compilation, useSourceMap), ); } }); } } ================================================ FILE: src/types.ts ================================================ import type { transform, TransformOptions } from 'esbuild'; type Filter = string | RegExp; type Implementation = { transform: typeof transform; }; type Except = { [Key in keyof ObjectType as (Key extends Properties ? never : Key)]: ObjectType[Key]; }; export type LoaderOptions = Except & { /** Pass a custom esbuild implementation */ implementation?: Implementation; /** * Path to tsconfig.json file */ tsconfig?: string; }; export type EsbuildPluginOptions = Except & { include?: Filter | Filter[]; exclude?: Filter | Filter[]; css?: boolean; /** Pass a custom esbuild implementation */ implementation?: Implementation; }; ================================================ FILE: tests/fixtures.ts ================================================ export const exportFile = ( name: string, code: string, ) => ({ '/src/index.js': `export { default } from "./${name}"`, [`/src/${name}`]: code, }); const trySyntax = ( name: string, code: string, ) => ` (() => { try { ${code} return ${JSON.stringify(name)}; } catch (error) { return error; } })() `; export const js = exportFile( 'js.js', `export default [${[ trySyntax( 'es2016 - Exponentiation operator', '10 ** 4', ), trySyntax( 'es2017 - Async functions', 'typeof (async () => {})', ), // trySyntax( // 'es2018 - Asynchronous iteration', // 'for await (let x of []) {}', // ), trySyntax( 'es2018 - Spread properties', 'let x = {...Object}', ), trySyntax( 'es2018 - Rest properties', 'let {...x} = Object', ), trySyntax( 'es2019 - Optional catch binding', 'try {} catch {}', ), trySyntax( 'es2020 - Optional chaining', 'Object?.keys', ), trySyntax( 'es2020 - Nullish coalescing', 'Object ?? true', ), trySyntax( 'es2020 - import.meta', 'import.meta', ), trySyntax( 'es2021 - Logical assignment operators', 'let a = false; a ??= true; a ||= true; a &&= true;', ), trySyntax( 'es2022 - Class instance fields', '(class { x })', ), trySyntax( 'es2022 - Static class fields', '(class { static x })', ), trySyntax( 'es2022 - Private instance methods', '(class { #x() {} })', ), trySyntax( 'es2022 - Private instance fields', '(class { #x })', ), trySyntax( 'es2022 - Private static methods', '(class { static #x() {} })', ), trySyntax( 'es2022 - Private static fields', '(class { static #x })', ), // trySyntax( // 'es2022 - Ergonomic brand checks', // '(class { #brand; static isC(obj) { return try obj.#brand; } })', // ), trySyntax( 'es2022 - Class static blocks', '(class { static {} })', ), // trySyntax( // 'esnext - Import assertions', // 'import "x" assert {}', // ), ].join(',')}];`, ); export const ts = exportFile( 'ts.ts', ` import type {Type} from 'foo' interface Foo {} type Foo = number declare module 'foo' {} enum BasicEnum { Left, Right, } enum NamedEnum { SomeEnum = 'some-value', } export const a = BasicEnum.Left; export const b = NamedEnum.SomeEnum; export default function foo(): string { return 'foo' } // For "ts as tsx" test const bar = (value: T) => fn(); `, ); export const blank = { '/src/index.js': '', }; export const minification = { '/src/index.js': 'export default ( stringVal ) => { return stringVal }', }; export const define = { '/src/index.js': 'export default () => [__TEST1__, __TEST2__]', }; export const getHelpers = { '/src/index.js': 'export default async () => {}', }; export const legalComments = { '/src/index.js': ` //! legal comment globalCall(); `, }; export const css = { '/src/index.js': 'import "./styles.css"', '/src/styles.css': ` div { color: red; } span { margin: 0px 10px; } `, }; ================================================ FILE: tests/index.ts ================================================ import { describe } from 'manten'; import webpack4 from 'webpack'; import webpack5 from 'webpack5'; import { loader } from './specs/loader.js'; import { plugin } from './specs/plugin.js'; const webpacks = [ webpack4, webpack5, ]; describe('esbuild-loader', async () => { for (const webpack of webpacks) { describe(`Webpack ${webpack.version![0]}`, () => { loader(webpack); plugin(webpack); }); } await import('./specs/tsconfig.js'); await import('./specs/webpack5.js'); }); ================================================ FILE: tests/specs/loader.ts ================================================ import { describe, test, expect } from 'manten'; import { build } from 'webpack-test-utils'; import type webpack4 from 'webpack'; import type webpack5 from 'webpack5'; import { type Webpack, configureEsbuildLoader, configureCssLoader, } from '../utils.js'; import * as fixtures from '../fixtures.js'; import type { EsbuildPluginOptions } from '#esbuild-loader'; const { exportFile } = fixtures; export const loader = (webpack: typeof webpack4 | typeof webpack5) => { describe('Loader', () => { describe('Error handling', () => { test('tsx fails to be parsed as ts', async () => { const built = await build( exportFile( 'tsx.tsx', 'export default
hello world
', ), (config) => { configureEsbuildLoader(config, { test: /\.tsx$/, options: { loader: 'ts', }, }); }, webpack, ); expect(built.stats.hasErrors()).toBe(true); const [error] = built.stats.compilation.errors; expect(error.message).toMatch('Transform failed with 1 error'); }); }); test('transforms syntax', async () => { const built = await build( fixtures.js, configureEsbuildLoader, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); expect(built.require('/dist')).toStrictEqual([ 'es2016 - Exponentiation operator', 'es2017 - Async functions', 'es2018 - Spread properties', 'es2018 - Rest properties', 'es2019 - Optional catch binding', 'es2020 - Optional chaining', 'es2020 - Nullish coalescing', 'es2020 - import.meta', 'es2021 - Logical assignment operators', 'es2022 - Class instance fields', 'es2022 - Static class fields', 'es2022 - Private instance methods', 'es2022 - Private instance fields', 'es2022 - Private static methods', 'es2022 - Private static fields', 'es2022 - Class static blocks', ]); }); test('transforms TypeScript', async () => { const built = await build( fixtures.ts, (config) => { configureEsbuildLoader(config, { test: /\.ts$/, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); expect(built.require('/dist')()).toBe('foo'); }); test('transforms TSX', async () => { const built = await build( exportFile( 'tsx.tsx', 'export default (<>
hello world
)', ), (config) => { configureEsbuildLoader(config, { test: /\.tsx$/, options: { jsxFactory: 'Array', jsxFragment: '"Fragment"', }, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); expect(built.require('/dist')).toStrictEqual([ 'Fragment', null, [ 'div', null, 'hello world', ], ]); }); test('tsconfig', async () => { const built = await build( exportFile( 'tsx.tsx', 'export default (
hello world
)', ), (config) => { configureEsbuildLoader(config, { test: /\.tsx$/, options: { tsconfigRaw: { compilerOptions: { jsxFactory: 'Array', }, }, }, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); expect(built.require('/dist/index.js')).toStrictEqual(['div', null, 'hello world']); }); describe('implementation', () => { test('error', async () => { const runWithImplementation = async ( implementation: EsbuildPluginOptions['implementation'], ) => { const built = await build( fixtures.blank, (config) => { configureEsbuildLoader(config, { options: { implementation, }, }); }, webpack, ); expect(built.stats.hasErrors()).toBe(true); const [error] = built.stats.compilation.errors; throw error; }; // @ts-expect-error testing invalid type await expect(runWithImplementation({})).rejects.toThrow( 'esbuild-loader: options.implementation.transform must be an ESBuild transform function. Received undefined', ); // @ts-expect-error testing invalid type await expect(runWithImplementation({ transform: 123 })).rejects.toThrow( 'esbuild-loader: options.implementation.transform must be an ESBuild transform function. Received number', ); }); test('custom transform function', async () => { const built = await build( fixtures.blank, (config) => { configureEsbuildLoader(config, { options: { implementation: { transform: async () => ({ code: 'export default "CUSTOM_ESBUILD_IMPLEMENTATION"', map: '', warnings: [], }), }, }, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const dist = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(dist).toContain('CUSTOM_ESBUILD_IMPLEMENTATION'); }); }); describe('ambigious ts/tsx', () => { test('ts via tsx', async () => { const built = await build( fixtures.ts, (config) => { configureEsbuildLoader(config, { test: /\.tsx?$/, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); expect(built.require('/dist')()).toBe('foo'); }); test('ts via tsx 2', async () => { const built = await build( exportFile( 'ts.ts', ` export default ( l: obj, options: { [key in obj]: V }, ): V => { return options[l]; }; `, ), (config) => { configureEsbuildLoader(config, { test: /\.tsx?$/, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); expect(built.require('/dist')('a', { a: 1 })).toBe(1); }); test('ambiguous ts', async () => { const built = await build( exportFile( 'ts.ts', 'export default () => 1/g', ), (config) => { configureEsbuildLoader(config, { test: /\.tsx?$/, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const dist = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(dist).toContain('(() => 1 < /a>/g)'); }); test('ambiguous tsx', async () => { const built = await build( exportFile( 'tsx.tsx', 'export default () => 1/g', ), (config) => { configureEsbuildLoader(config, { test: /\.tsx?$/, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const dist = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(dist).toContain('React.createElement'); }); }); describe('Source-map', () => { test('source-map eval', async () => { const built = await build( fixtures.js, (config) => { configureEsbuildLoader(config); config.devtool = 'eval-source-map'; }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const dist = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(dist).toContain('eval('); }); test('source-map inline', async () => { const built = await build( fixtures.js, (config) => { configureEsbuildLoader(config); config.devtool = 'inline-source-map'; }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const dist = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(dist).toContain('sourceMappingURL'); }); test('source-map file', async () => { const built = await build( fixtures.js, (config) => { configureEsbuildLoader(config); config.devtool = 'source-map'; }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const { assets } = built.stats.compilation; expect(assets).toHaveProperty(['index.js']); expect(assets).toHaveProperty(['index.js.map']); }); test('source-map plugin', async () => { const built = await build( fixtures.js, (config) => { configureEsbuildLoader(config); delete config.devtool; config.plugins!.push( new webpack.SourceMapDevToolPlugin({}) as Webpack['SourceMapDevToolPlugin'], ); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const dist = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(dist).toContain('sourceMappingURL'); }); }); test('webpack magic comments', async () => { const built = await build({ '/src/index.js': ` const chunkA = import(/* webpackChunkName: "named-chunk-foo" */'./chunk-a.js') const chunkB = import(/* webpackChunkName: "named-chunk-bar" */'./chunk-b.js') export default async () => (await chunkA).default + (await chunkB).default; `, '/src/chunk-a.js': 'export default 1', '/src/chunk-b.js': 'export default 2', }, configureEsbuildLoader, webpack); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const { assets } = built.stats.compilation; expect(assets).toHaveProperty(['index.js']); expect(assets).toHaveProperty(['named-chunk-foo.js']); expect(assets).toHaveProperty(['named-chunk-bar.js']); expect(await built.require('/dist')()).toBe(3); }); test('CSS minification', async () => { const built = await build( fixtures.css, (config) => { configureEsbuildLoader(config); const cssRule = configureCssLoader(config); cssRule.use.push({ loader: 'esbuild-loader', options: { minify: true, }, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const code = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(code).toContain('div{color:red}'); }); test('Keeps dynamic imports by default', async () => { const built = await build( { '/src/index.js': 'export default async () => (await import("./test2.js")).default', '/src/test2.js': 'export default "test2"', }, (config) => { configureEsbuildLoader(config, { options: { target: 'chrome52' } }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const { assets } = built.stats.compilation; expect(assets).toHaveProperty(['index.js']); // Chunk split because esbuild preserved the dynamic import expect(Object.keys(assets).length).toBe(2); expect(await built.require('/dist')()).toBe('test2'); }); test('Dynamic imports can be disabled', async () => { const built = await build( { '/src/index.js': 'export default async () => (await import("./test2.js")).default', '/src/test2.js': 'export default "test2"', }, (config) => { configureEsbuildLoader(config, { options: { target: 'chrome52', supported: { 'dynamic-import': false }, }, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const { assets } = built.stats.compilation; expect(assets).toHaveProperty(['index.js']); // No chunk split because esbuild removed the dynamic import expect(Object.keys(assets).length).toBe(1); expect(await built.require('/dist')()).toBe('test2'); }); test('define replaces identifiers', async () => { const built = await build( { '/src/index.js': 'export default MY_CONSTANT', }, (config) => { configureEsbuildLoader(config, { options: { define: { MY_CONSTANT: JSON.stringify('replaced-value'), }, }, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); expect(built.require('/dist')).toBe('replaced-value'); }); test('define works with eval devtool in loader', async () => { const built = await build( { '/src/index.js': 'export default MY_CONSTANT', }, (config) => { configureEsbuildLoader(config, { options: { define: { MY_CONSTANT: JSON.stringify('works-with-eval'), }, }, }); config.devtool = 'eval-source-map'; }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); // Loader's define works with eval devtools because it runs BEFORE bundling expect(built.require('/dist')).toBe('works-with-eval'); }); }); }; ================================================ FILE: tests/specs/plugin.ts ================================================ import { describe, test, expect } from 'manten'; import { build } from 'webpack-test-utils'; import type webpack4 from 'webpack'; import webpack5 from 'webpack5'; import * as esbuild from 'esbuild'; import { merge } from 'webpack-merge'; import { type Webpack, isWebpack4, configureEsbuildMinifyPlugin, configureMiniCssExtractPlugin, } from '../utils.js'; import * as fixtures from '../fixtures.js'; import { EsbuildPlugin, type EsbuildPluginOptions } from '#esbuild-loader'; const assertMinified = (code: string) => { expect(code).not.toMatch(/\s{2,}/); expect(code).not.toMatch('stringVal'); expect(code).not.toMatch('return '); }; const countIife = (code: string) => Array.from(code.matchAll(/\(\(\)=>\{/g)).length; export const plugin = (webpack: typeof webpack4 | typeof webpack5) => { const webpackIs4 = isWebpack4(webpack); describe('Plugin', () => { describe('Minify JS', () => { describe('should not minify by default', () => { test('minimizer', async () => { const built = await build( fixtures.minification, (config) => { config.optimization = { minimize: false, minimizer: [ new EsbuildPlugin(), ], }; }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const exportedFunction = built.require('/dist/'); expect(exportedFunction('hello world')).toBe('hello world'); expect(exportedFunction.toString()).toMatch(/\s{2,}/); }); test('plugin', async () => { const built = await build( fixtures.minification, (config) => { config.plugins?.push(new EsbuildPlugin()); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const exportedFunction = built.require('/dist/'); expect(exportedFunction('hello world')).toBe('hello world'); expect(exportedFunction.toString()).toMatch(/\s{2,}/); }); test('plugin with minimize enabled', async () => { const built = await build( fixtures.minification, (config) => { config.optimization = { minimize: true, // Remove Terser minimizer: [], }; config.plugins?.push(new EsbuildPlugin()); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const exportedFunction = built.require('/dist/'); expect(exportedFunction('hello world')).toBe('hello world'); expect(exportedFunction.toString()).toMatch(/\s{2,}/); }); }); test('minify', async () => { const built = await build( fixtures.minification, (config) => { configureEsbuildMinifyPlugin(config); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const exportedFunction = built.require('/dist/'); expect(exportedFunction('hello world')).toBe('hello world'); assertMinified(exportedFunction.toString()); }); test('minifyWhitespace', async () => { const built = await build( fixtures.minification, (config) => { configureEsbuildMinifyPlugin(config, { minifyWhitespace: true, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const exportedFunction = built.require('/dist/'); expect(exportedFunction('hello world')).toBe('hello world'); const code = exportedFunction.toString(); expect(code).not.toMatch(/\s{2,}/); expect(code).toMatch('stringVal'); expect(code).toMatch('return '); }); test('minifyIdentifiers', async () => { const built = await build( fixtures.minification, (config) => { configureEsbuildMinifyPlugin(config, { minifyIdentifiers: true, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const exportedFunction = built.require('/dist/'); expect(exportedFunction('hello world')).toBe('hello world'); const code = exportedFunction.toString(); expect(code).toMatch(/\s{2,}/); expect(code).not.toMatch('stringVal'); expect(code).toMatch('return '); }); test('minifySyntax', async () => { const built = await build( fixtures.minification, (config) => { configureEsbuildMinifyPlugin(config, { minifySyntax: true, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const exportedFunction = built.require('/dist/'); expect(exportedFunction('hello world')).toBe('hello world'); const code = exportedFunction.toString(); expect(code).toMatch(/\s/); expect(code).toMatch('stringVal'); expect(code).not.toMatch('return '); }); test('should minify when used alongside plugin', async () => { const built = await build( fixtures.minification, (config) => { configureEsbuildMinifyPlugin(config); config.plugins?.push(new EsbuildPlugin()); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const exportedFunction = built.require('/dist/'); expect(exportedFunction('hello world')).toBe('hello world'); assertMinified(exportedFunction.toString()); }); test('minify chunks & filter using include/exclude', async () => { const built = await build({ '/src/index.js': ` const foo = import(/* webpackChunkName: "named-chunk-foo" */'./foo.js') const bar = import(/* webpackChunkName: "named-chunk-bar" */'./bar.js') const baz = import(/* webpackChunkName: "named-chunk-baz" */'./baz.js') export default [foo, bar, baz]; `, '/src/foo.js': fixtures.minification['/src/index.js'], '/src/bar.js': fixtures.minification['/src/index.js'], '/src/baz.js': fixtures.minification['/src/index.js'], }, (config) => { configureEsbuildMinifyPlugin(config, { include: /ba./, exclude: /baz/, }); }, webpack); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const chunkFoo = built.fs.readFileSync('/dist/named-chunk-foo.js', 'utf8').toString(); // The string "__webpack_require__" is only present in unminified chunks expect(chunkFoo).toContain('__webpack_require__'); const chunkBar = built.fs.readFileSync('/dist/named-chunk-bar.js', 'utf8').toString(); expect(chunkBar).not.toContain('__webpack_require__'); assertMinified(chunkBar); const chunkBaz = built.fs.readFileSync('/dist/named-chunk-baz.js', 'utf8').toString(); expect(chunkBaz).toContain('__webpack_require__'); }); describe('devtool', () => { test('minify w/ no devtool', async () => { const built = await build( fixtures.blank, (config) => { delete config.devtool; configureEsbuildMinifyPlugin(config); }, webpack, ); const { stats } = built; expect(stats.hasWarnings()).toBe(false); expect(stats.hasErrors()).toBe(false); expect( Object.keys(stats.compilation.assets).length, ).toBe(1); const file = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(file).not.toContain('//# sourceURL'); }); test('minify w/ devtool inline-source-map', async () => { const built = await build( fixtures.blank, (config) => { config.devtool = 'inline-source-map'; configureEsbuildMinifyPlugin(config); }, webpack, ); const { stats } = built; expect(stats.hasWarnings()).toBe(false); expect(stats.hasErrors()).toBe(false); expect( Object.keys(stats.compilation.assets).length, ).toBe(1); const file = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(file).toContain('//# sourceMappingURL=data:application/'); }); test('minify w/ devtool source-map', async () => { const built = await build( fixtures.blank, (config) => { config.devtool = 'source-map'; configureEsbuildMinifyPlugin(config); }, webpack, ); const { stats } = built; expect(stats.hasWarnings()).toBe(false); expect(stats.hasErrors()).toBe(false); expect( Object.keys(stats.compilation.assets), ).toStrictEqual([ 'index.js', 'index.js.map', ]); const file = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(file).toContain('//# sourceMappingURL=index.js.map'); }); test('minify w/ source-map option and source-map plugin inline', async () => { const built = await build( fixtures.blank, (config) => { delete config.devtool; configureEsbuildMinifyPlugin(config); config.plugins!.push( new webpack.SourceMapDevToolPlugin({}) as Webpack['SourceMapDevToolPlugin'], ); }, webpack, ); const { stats } = built; expect(stats.hasWarnings()).toBe(false); expect(stats.hasErrors()).toBe(false); expect( Object.keys(stats.compilation.assets).length, ).toBe(1); const file = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(file).toContain('//# sourceMappingURL=data:application/'); }); test('minify w/ source-map option and source-map plugin external', async () => { const built = await build( fixtures.blank, (config) => { delete config.devtool; configureEsbuildMinifyPlugin(config); config.plugins!.push( new webpack.SourceMapDevToolPlugin({ filename: 'index.js.map', }) as Webpack['SourceMapDevToolPlugin'], ); }, webpack, ); const { stats } = built; expect(stats.hasWarnings()).toBe(false); expect(stats.hasErrors()).toBe(false); expect( Object.keys(stats.compilation.assets), ).toStrictEqual([ 'index.js', 'index.js.map', ]); const file = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(file).toContain('//# sourceMappingURL=index.js.map'); }); }); test('minify w/ query strings', async () => { const built = await build( { '/src/index.js': 'import(/* webpackChunkName: "chunk" */"./chunk.js")', '/src/chunk.js': '', }, (config) => { config.output!.filename = '[name].js?foo=bar'; config.output!.chunkFilename = '[name].js?foo=bar'; configureEsbuildMinifyPlugin(config); }, webpack, ); const { stats } = built; expect(stats.hasWarnings()).toBe(false); expect(stats.hasErrors()).toBe(false); expect( Object.keys(stats.compilation.assets).sort(), ).toStrictEqual([ 'chunk.js?foo=bar', 'index.js?foo=bar', ]); // The actual file name does not include the query string const file = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(file).toMatch('?foo=bar'); }); describe('legalComments', () => { test('minify w/ legalComments - default is inline', async () => { const builtDefault = await build( fixtures.legalComments, (config) => { configureEsbuildMinifyPlugin(config); }, webpack, ); const builtInline = await build( fixtures.legalComments, (config) => { configureEsbuildMinifyPlugin(config, { legalComments: 'inline', }); }, webpack, ); const fileInline = builtInline.fs.readFileSync('/dist/index.js', 'utf8'); const fileDefault = builtDefault.fs.readFileSync('/dist/index.js', 'utf8'); expect(fileDefault).toMatch('//! legal comment'); expect(fileDefault).toBe(fileInline); }); test('minify w/ legalComments - eof', async () => { const built = await build( fixtures.legalComments, (config) => { configureEsbuildMinifyPlugin(config, { legalComments: 'eof', }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const file = built.fs.readFileSync('/dist/index.js').toString(); expect(file.trim().endsWith('//! legal comment')).toBe(true); }); test('minify w/ legalComments - none', async () => { const built = await build( fixtures.legalComments, (config) => { configureEsbuildMinifyPlugin(config, { legalComments: 'none', }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const file = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(file).not.toMatch('//! legal comment'); }); test('minify w/ legalComments - external', async () => { const built = await build( fixtures.legalComments, (config) => { configureEsbuildMinifyPlugin(config, { legalComments: 'external', }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); expect(Object.keys(built.stats.compilation.assets)).toStrictEqual([ 'index.js', 'index.js.LEGAL.txt', ]); const file = built.fs.readFileSync('/dist/index.js', 'utf8'); expect(file).not.toMatch('//! legal comment'); const extracted = built.fs.readFileSync('/dist/index.js.LEGAL.txt', 'utf8'); expect(extracted).toMatch('//! legal comment'); }); }); }); describe('implementation', () => { test('error', async () => { const runWithImplementation = async (implementation: EsbuildPluginOptions['implementation']) => { await build( fixtures.blank, (config) => { configureEsbuildMinifyPlugin(config, { implementation, }); }, webpack, ); }; await expect( // @ts-expect-error testing invalid type runWithImplementation({}), ).rejects.toThrow( '[EsbuildPlugin] implementation.transform must be an esbuild transform function. Received undefined', ); await expect( // @ts-expect-error testing invalid type runWithImplementation({ transform: 123 }), ).rejects.toThrow( '[EsbuildPlugin] implementation.transform must be an esbuild transform function. Received number', ); }); test('customizable', async () => { const code = 'export function foo() { return "CUSTOM_ESBUILD_IMPLEMENTATION"; }'; const built = await build( fixtures.blank, (config) => { configureEsbuildMinifyPlugin(config, { implementation: { transform: async () => ({ code, map: '', warnings: [], mangleCache: {}, legalComments: '', }), }, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); expect( built.fs.readFileSync('/dist/index.js', 'utf8'), ).toBe(code); }); test('customize with real esbuild', async () => { const built = await build( fixtures.minification, (config) => { configureEsbuildMinifyPlugin(config, { implementation: esbuild, }); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const exportedFunction = built.require('/dist/'); expect(exportedFunction('hello world')).toBe('hello world'); assertMinified(exportedFunction.toString()); }); }); describe('CSS', () => { test('minify CSS asset', async () => { const built = await build( fixtures.css, (config) => { configureEsbuildMinifyPlugin(config, { css: true, }); configureMiniCssExtractPlugin(config); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const file = built.fs.readFileSync('/dist/index.css').toString(); expect(file.trim()).not.toMatch(/\s{2,}/); }); test('exclude', async () => { const built = await build( fixtures.css, (config) => { configureEsbuildMinifyPlugin(config, { css: true, exclude: /index\.css$/, }); configureMiniCssExtractPlugin(config); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const file = built.fs.readFileSync('/dist/index.css').toString(); expect(file.trim()).toMatch(/\s{2,}/); }); test('minify w/ source-map', async () => { const built = await build( fixtures.css, (config) => { config.devtool = 'source-map'; configureEsbuildMinifyPlugin(config, { css: true, }); configureMiniCssExtractPlugin(config); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const cssFile = built.fs.readFileSync('/dist/index.css').toString(); const css = cssFile.trim().split('\n'); expect(css[0]).not.toMatch(/\s{2,}/); expect(css[2]).toMatch(/sourceMappingURL/); const sourcemapFile = built.fs.readFileSync('/dist/index.css.map', 'utf8'); expect(sourcemapFile).toMatch(/styles\.css/); }); }); test('supports Source without #sourceAndMap()', async () => { const createSource = (content: string) => ({ source: () => content, size: () => Buffer.byteLength(content), }) as webpack5.sources.Source; const built = await build(fixtures.blank, (config) => { configureEsbuildMinifyPlugin(config); config.plugins!.push({ apply: (compiler) => { compiler.hooks.compilation.tap('test', (compilation) => { compilation.hooks.processAssets.tap( { name: 'test' }, () => { compilation.emitAsset( 'test.js', createSource('console.log( 1 + 1)'), ); }, ); }); }, }); }, webpack5); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); expect(Object.keys(built.stats.compilation.assets)).toStrictEqual([ 'index.js', 'test.js', ]); expect( built.fs.readFileSync('/dist/test.js', 'utf8'), ).toBe('console.log(2);\n'); }); describe('minify targets', () => { test('no iife for node', async () => { const built = await build( fixtures.getHelpers, (config) => { configureEsbuildMinifyPlugin(config, { target: 'es2015', }); config.target = webpackIs4 ? 'node' : ['node']; delete config.output?.libraryTarget; delete config.output?.libraryExport; }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const code = built.fs.readFileSync('/dist/index.js', 'utf8').toString(); expect(code.startsWith('var ')).toBe(true); }); test('no iife for web with high target (no helpers are added)', async () => { const built = await build( fixtures.getHelpers, (config) => { configureEsbuildMinifyPlugin(config); config.target = webpackIs4 ? 'web' : ['web']; delete config.output?.libraryTarget; delete config.output?.libraryExport; }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const code = built.fs.readFileSync('/dist/index.js', 'utf8').toString(); expect(code.startsWith('(()=>{var ')).toBe(false); expect(countIife(code)).toBe(webpackIs4 ? 0 : 1); }); test('iife for web & low target', async () => { const built = await build( fixtures.getHelpers, (config) => { configureEsbuildMinifyPlugin(config, { target: 'es2015', }); config.target = webpackIs4 ? 'web' : ['web']; delete config.output?.libraryTarget; delete config.output?.libraryExport; }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const code = built.fs.readFileSync('/dist/index.js', 'utf8').toString(); expect(code.startsWith('(()=>{var ')).toBe(true); expect(code.endsWith('})();\n')).toBe(true); expect(countIife(code)).toBe(webpackIs4 ? 1 : 2); }); }); test('supports webpack-merge', async () => { const built = await build( fixtures.minification, (config) => { configureEsbuildMinifyPlugin(config); const clonedConfig = merge({}, config); config.optimization = clonedConfig.optimization; }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const exportedFunction = built.require('/dist/'); expect(exportedFunction('hello world')).toBe('hello world'); assertMinified(exportedFunction.toString()); }); // https://github.com/privatenumber/esbuild-loader/issues/356 test('can handle empty modules set', async () => { await expect(build( fixtures.blank, (config) => { config.entry = 'not-there.js'; configureEsbuildMinifyPlugin(config); }, webpack, )).resolves.toBeTruthy(); }); test('multiple plugins', async () => { const built = await build( fixtures.define, (config) => { configureEsbuildMinifyPlugin(config); config.plugins?.push( new EsbuildPlugin({ define: { __TEST1__: '123', }, }), new EsbuildPlugin({ define: { __TEST2__: '321', }, }), ); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const exportedFunction = built.require('/dist/'); expect(exportedFunction('hello world')).toStrictEqual([123, 321]); }); test('warns when define is used with eval devtool', async () => { const built = await build( fixtures.minification, (config) => { config.devtool = 'eval-source-map'; config.plugins?.push( new EsbuildPlugin({ define: { __TEST__: '"value"', }, }), ); }, webpack, ); expect(built.stats.hasWarnings()).toBe(true); const warnings = built.stats.toJson().warnings!; expect(warnings.length).toBe(1); // Webpack 4 returns string, Webpack 5 returns object with message property const warningMessage = typeof warnings[0] === 'string' ? warnings[0] : warnings[0].message; expect(warningMessage).toMatch('[EsbuildPlugin] The "define" option may not work as expected with eval-based devtools (current: "eval-source-map")'); expect(warningMessage).toMatch('Consider using the loader\'s "define" option instead'); }); test('no warning when define is used with non-eval devtool', async () => { const built = await build( fixtures.minification, (config) => { config.devtool = 'source-map'; config.plugins?.push( new EsbuildPlugin({ define: { __TEST__: '"value"', }, }), ); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); }); test('no warning when define is not used with eval devtool', async () => { const built = await build( fixtures.minification, (config) => { config.devtool = 'eval-source-map'; config.plugins?.push(new EsbuildPlugin()); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); }); test('no warning when define is empty with eval devtool', async () => { const built = await build( fixtures.minification, (config) => { config.devtool = 'eval-source-map'; config.plugins?.push( new EsbuildPlugin({ define: {}, }), ); }, webpack, ); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); }); }); }; ================================================ FILE: tests/specs/tsconfig.ts ================================================ import path from 'path'; import { createRequire } from 'node:module'; import { describe, test, expect } from 'manten'; import { createFixture } from 'fs-fixture'; import { execa } from 'execa'; import { tsconfigJson } from '../utils.js'; const require = createRequire(import.meta.url); const webpackCli = path.resolve('node_modules/webpack-cli/bin/cli.js'); const esbuildLoader = path.resolve('dist/index.cjs'); const detectStrictMode = '(function() { return !this; })()'; describe('tsconfig', () => { describe('loader', () => { test('auto-detects tsconfig and applies to all files regardless of include patterns', async () => { await using fixture = await createFixture({ src: { 'index.ts': `module.exports = [ ${detectStrictMode}, require("./lib.ts"), require("./nested/also-strict.ts"), ];`, 'lib.ts': `module.exports = ${detectStrictMode}`, nested: { 'also-strict.ts': `module.exports = ${detectStrictMode}`, 'tsconfig.json': tsconfigJson({ compilerOptions: { strict: true, }, }), }, }, 'webpack.config.js': ` module.exports = { mode: 'production', optimization: { minimize: false, }, resolveLoader: { alias: { 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, }, }, module: { rules: [{ test: /\\.ts$/, loader: 'esbuild-loader', }], }, entry: './src/index.ts', output: { libraryTarget: 'commonjs2', }, }; `, 'tsconfig.json': tsconfigJson({ compilerOptions: { strict: true, }, include: [ 'src/index.ts', ], }), }); await execa(webpackCli, { cwd: fixture.path, }); // All files get strict mode from their nearest tsconfig // lib.ts is NOT in include pattern but still gets strict mode expect( require(path.join(fixture.path, 'dist/main.js')), ).toStrictEqual([true, true, true]); }); test('handles resource with query string', async () => { await using fixture = await createFixture({ src: { 'index.ts': `module.exports = [${detectStrictMode}, require("./lib.ts?some-query")];`, 'lib.ts': `module.exports = ${detectStrictMode}`, }, 'webpack.config.js': ` module.exports = { mode: 'production', optimization: { minimize: false, }, resolveLoader: { alias: { 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, }, }, module: { rules: [{ test: /\\.ts$/, loader: 'esbuild-loader', }], }, entry: './src/index.ts', output: { libraryTarget: 'commonjs2', }, }; `, 'tsconfig.json': tsconfigJson({ compilerOptions: { strict: true, }, }), }); await execa(webpackCli, { cwd: fixture.path, }); // Both files get strict mode even with query string in require expect( require(path.join(fixture.path, 'dist/main.js')), ).toStrictEqual([true, true]); }); test('applies custom tsconfig to all files regardless of include patterns', async () => { await using fixture = await createFixture({ src: { 'utils/lib.ts': `module.exports = ${detectStrictMode}`, 'app/index.ts': `module.exports = [${detectStrictMode}, require("../utils/lib.ts")];`, }, 'webpack.config.js': ` module.exports = { mode: 'production', optimization: { minimize: false, }, resolveLoader: { alias: { 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, }, }, module: { rules: [{ test: /\\.ts$/, loader: 'esbuild-loader', options: { tsconfig: './tsconfig.json', } }], }, entry: './src/app/index.ts', output: { libraryTarget: 'commonjs2', }, }; `, 'tsconfig.json': tsconfigJson({ compilerOptions: { strict: true, }, include: [ 'src/app/**/*', ], }), }); const { stdout, exitCode } = await execa(webpackCli, { cwd: fixture.path, }); // Should NOT produce warnings even though lib.ts is not in include patterns // TypeScript applies tsconfig to all imports expect(stdout).not.toMatch('does not match its "include" patterns'); expect(exitCode).toBe(0); // Both files should have strict mode applied expect( require(path.join(fixture.path, 'dist/main.js')), ).toStrictEqual([true, true]); }); test('applies different tsconfig.json paths', async () => { await using fixture = await createFixture({ src: { 'index.ts': 'export class C { foo = 100; }', 'index2.ts': 'export class C { foo = 100; }', }, 'webpack.config.js': ` module.exports = { mode: 'production', optimization: { minimize: false, }, resolveLoader: { alias: { 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, }, }, module: { rules: [ { test: /index\\.ts$/, loader: 'esbuild-loader', options: { tsconfig: './tsconfig.custom1.json', } }, { test: /index2\\.ts$/, loader: 'esbuild-loader', options: { tsconfig: './tsconfig.custom2.json', } } ], }, entry: { index1: './src/index.ts', index2: './src/index2.ts', }, output: { libraryTarget: 'commonjs2', }, }; `, 'tsconfig.custom1.json': tsconfigJson({ compilerOptions: { useDefineForClassFields: false, }, }), 'tsconfig.custom2.json': tsconfigJson({ compilerOptions: { useDefineForClassFields: true, }, }), }); await execa(webpackCli, { cwd: fixture.path, }); const code1 = await fixture.readFile('dist/index1.js', 'utf8'); expect(code1).toMatch('this.foo = 100;'); const code2 = await fixture.readFile('dist/index2.js', 'utf8'); expect(code2).toMatch('__publicField(this, "foo", 100);'); }); test('fails on invalid tsconfig.json', async () => { await using fixture = await createFixture({ 'tsconfig.json': tsconfigJson({ extends: 'unresolvable-dep', }), src: { 'index.ts': ` console.log('Hello, world!' as numer); `, }, 'webpack.config.js': ` module.exports = { mode: 'production', optimization: { minimize: false, }, resolveLoader: { alias: { 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, }, }, resolve: { extensions: ['.ts', '.js'], }, module: { rules: [ { test: /.[tj]sx?$/, loader: 'esbuild-loader', options: { target: 'es2015', } } ], }, entry: { index: './src/index.ts', }, }; `, }); const { stdout, exitCode } = await execa(webpackCli, { cwd: fixture.path, reject: false, }); expect(stdout).toMatch('Error parsing tsconfig.json:\nFile \'unresolvable-dep\' not found.'); expect(exitCode).toBe(1); }); test('ignores invalid tsconfig.json in JS dependencies', async () => { await using fixture = await createFixture({ 'node_modules/fake-lib': { 'package.json': JSON.stringify({ name: 'fake-lib', }), 'tsconfig.json': tsconfigJson({ extends: 'unresolvable-dep', }), 'index.js': 'export function testFn() { return "Hi!" }', }, 'src/index.ts': ` import { testFn } from "fake-lib"; testFn(); `, 'webpack.config.js': ` module.exports = { mode: 'production', optimization: { minimize: false, }, resolveLoader: { alias: { 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, }, }, resolve: { extensions: ['.ts', '.js'], }, module: { rules: [ { test: /.[tj]sx?$/, loader: 'esbuild-loader', options: { target: 'es2015', } } ], }, entry: { index: './src/index.ts', }, }; `, }); const { stdout, exitCode } = await execa(webpackCli, { cwd: fixture.path, }); expect(stdout).not.toMatch('Error parsing tsconfig.json'); expect(exitCode).toBe(0); }); test('warns on invalid tsconfig.json in TS dependencies', async () => { await using fixture = await createFixture({ 'node_modules/fake-lib': { 'package.json': JSON.stringify({ name: 'fake-lib', }), 'tsconfig.json': tsconfigJson({ extends: 'unresolvable-dep', }), 'index.ts': 'export function testFn(): string { return "Hi!" }', }, 'src/index.ts': ` import { testFn } from "fake-lib"; testFn(); `, 'webpack.config.js': ` module.exports = { mode: 'production', optimization: { minimize: false, }, resolveLoader: { alias: { 'esbuild-loader': ${JSON.stringify(esbuildLoader)}, }, }, resolve: { extensions: ['.ts', '.js'], }, module: { rules: [ { test: /.[tj]sx?$/, loader: 'esbuild-loader', options: { target: 'es2015', } } ], }, entry: { index: './src/index.ts', }, }; `, }); const { stdout, exitCode } = await execa(webpackCli, { cwd: fixture.path, }); expect(stdout).toMatch('Error parsing tsconfig.json:\nFile \'unresolvable-dep\' not found.'); // Warning so doesn't fail expect(exitCode).toBe(0); }); }); describe('plugin', () => { /** * Since the plugin applies on distribution assets, it should not apply * any tsconfig settings. */ test('should not detect tsconfig.json and apply strict mode', async () => { await using fixture = await createFixture({ src: { 'index.js': 'console.log(1)', }, 'webpack.config.js': ` const { EsbuildPlugin } = require(${JSON.stringify(esbuildLoader)}); module.exports = { mode: 'production', optimization: { minimizer: [ new EsbuildPlugin(), ], }, entry: './src/index.js', }; `, 'tsconfig.json': tsconfigJson({ compilerOptions: { strict: true, }, }), }); await execa(webpackCli, { cwd: fixture.path, }); const code = await fixture.readFile('dist/main.js', 'utf8'); expect(code).not.toMatch('use strict'); }); }); }); ================================================ FILE: tests/specs/webpack5.ts ================================================ import { describe, test, expect } from 'manten'; import { build } from 'webpack-test-utils'; import webpack5 from 'webpack5'; import { configureEsbuildMinifyPlugin } from '../utils.js'; const { RawSource } = webpack5.sources; describe('Webpack 5', () => { test('Stats', async () => { const built = await build({ '/src/index.js': '' }, (config) => { configureEsbuildMinifyPlugin(config); }, webpack5); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); expect(built.stats.toString().includes('[minimized]')).toBe(true); }); test('Minifies new assets', async () => { const built = await build({ '/src/index.js': '' }, (config) => { configureEsbuildMinifyPlugin(config); config.plugins!.push({ apply: (compiler) => { compiler.hooks.compilation.tap('test', (compilation) => { compilation.hooks.processAssets.tap( { name: 'test' }, () => { compilation.emitAsset( 'test.js', new RawSource('const value = 1;\n\nexport default value;'), ); }, ); }); }, }); }, webpack5); expect(built.stats.hasWarnings()).toBe(false); expect(built.stats.hasErrors()).toBe(false); const asset = built.stats.compilation.getAsset('test.js'); expect(asset!.info.minimized).toBe(true); const file = built.fs.readFileSync('/dist/test.js', 'utf8'); expect(file).toBe('const e=1;export default 1;\n'); }); test('Doesnt minify minimized assets', async () => { let sourceAndMapCalled = false; await build({ '/src/index.js': '' }, (config) => { configureEsbuildMinifyPlugin(config); config.plugins!.push({ apply: (compiler) => { compiler.hooks.compilation.tap('test', (compilation) => { compilation.hooks.processAssets.tap( { name: 'test' }, () => { const asset = new RawSource(''); // @ts-expect-error overwriting to make sure it's not called asset.sourceAndMap = () => { sourceAndMapCalled = true; }; compilation.emitAsset( 'test.js', asset, { minimized: true }, ); }, ); }); }, }); }, webpack5); expect(sourceAndMapCalled).toBe(false); }); }); ================================================ FILE: tests/utils.ts ================================================ import path from 'path'; import type webpack4 from 'webpack'; import type webpack5 from 'webpack5'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import type { TsConfigJson } from 'get-tsconfig'; import { EsbuildPlugin, type EsbuildPluginOptions } from '#esbuild-loader'; const esbuildLoaderPath = path.resolve('./dist/index.cjs'); type Webpack4 = typeof webpack4; type Webpack5 = typeof webpack5; export type Webpack = Webpack4 & Webpack5; export type WebpackConfiguration = webpack4.Configuration | webpack5.Configuration; type RuleSetUseItem = webpack4.RuleSetUseItem & webpack5.RuleSetUseItem; type RuleSetRule = webpack4.RuleSetRule & webpack5.RuleSetRule; export const isWebpack4 = ( webpack: Webpack4 | Webpack5, ): webpack is Webpack4 => Boolean(webpack.version?.startsWith('4.')); export const configureEsbuildLoader = ( config: WebpackConfiguration, rulesConfig?: RuleSetRule, ) => { config.resolveLoader!.alias = { 'esbuild-loader': esbuildLoaderPath, }; config.module!.rules!.push({ test: /\.js$/, loader: 'esbuild-loader', ...rulesConfig, options: { tsconfigRaw: undefined, ...( typeof rulesConfig?.options === 'object' ? rulesConfig.options : {} ), }, }); }; export const configureEsbuildMinifyPlugin = ( config: WebpackConfiguration, options?: EsbuildPluginOptions, ) => { config.optimization = { minimize: true, minimizer: [ new EsbuildPlugin({ tsconfigRaw: undefined, ...options, }), ], }; }; export const configureCssLoader = ( config: WebpackConfiguration, ) => { const cssRule = { test: /\.css$/, use: [ 'css-loader', ] as RuleSetUseItem[], }; config.module!.rules!.push(cssRule); return cssRule; }; export const configureMiniCssExtractPlugin = ( config: WebpackConfiguration, ) => { const cssRule = configureCssLoader(config); cssRule.use.unshift(MiniCssExtractPlugin.loader); config.plugins!.push( // @ts-expect-error Forcing it to Webpack 5 new MiniCssExtractPlugin(), ); }; export const tsconfigJson = ( tsconfigObject: TsConfigJson, ) => JSON.stringify(tsconfigObject); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es2022", "lib": [ "ESNext", ], "moduleDetection": "force", "module": "preserve", "resolveJsonModule": true, "allowJs": true, "strict": true, // "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noEmit": true, "esModuleInterop": true, "verbatimModuleSyntax": true, "skipLibCheck": true, }, }