4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | esbuild-loader
5 |
6 |
7 |
8 |
9 | Speed up your Webpack build with [esbuild](https://github.com/evanw/esbuild)! 🔥
10 |
11 | [_esbuild_](https://github.com/evanw/esbuild) is a JavaScript bundler written in Go that supports blazing fast ESNext & TypeScript transpilation and [JS minification](https://github.com/privatenumber/minification-benchmarks/).
12 |
13 | [_esbuild-loader_](https://github.com/privatenumber/esbuild-loader) lets you harness the speed of esbuild in your Webpack build by offering faster alternatives for transpilation (eg. `babel-loader`/`ts-loader`) and minification (eg. Terser)!
14 |
15 | > [!TIP]
16 | > **Are you using TypeScript with Node.js?**
17 | >
18 | > Supercharge your Node.js with TypeScript support using _tsx_!
19 | >
20 | > _tsx_ is a simple, lightweight, and blazing fast alternative to ts-node.
21 | >
22 | > [→ Learn more about _tsx_](https://github.com/privatenumber/tsx)
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | Already a sponsor? Join the discussion in the Development repo!
31 |
32 | ## 🚀 Install
33 |
34 | ```bash
35 | npm i -D esbuild-loader
36 | ```
37 |
38 | ## 🚦 Quick Setup
39 |
40 | To leverage `esbuild-loader` in your Webpack configuration, add a new rule for `esbuild-loader` matching the files you want to transform, such as `.js`, `.jsx`, `.ts`, or `.tsx`. Make sure to remove any other loaders you were using before (e.g. `babel-loader`/`ts-loader`).
41 |
42 | Here's an example of how to set it up in your `webpack.config.js`:
43 |
44 | ```diff
45 | module.exports = {
46 | module: {
47 | rules: [
48 | - // Transpile JavaScript
49 | - {
50 | - test: /\.js$/,
51 | - use: 'babel-loader'
52 | - },
53 | -
54 | - // Compile TypeScript
55 | - {
56 | - test: /\.tsx?$/,
57 | - use: 'ts-loader'
58 | - },
59 | + // Use esbuild to compile JavaScript & TypeScript
60 | + {
61 | + // Match `.js`, `.jsx`, `.ts` or `.tsx` files
62 | + test: /\.[jt]sx?$/,
63 | + loader: 'esbuild-loader',
64 | + options: {
65 | + // JavaScript version to compile to
66 | + target: 'es2015'
67 | + }
68 | + },
69 |
70 | // Other rules...
71 | ],
72 | },
73 | }
74 | ```
75 |
76 | In this setup, esbuild will automatically determine how to handle each file based on its extension:
77 | - `.js` files will be treated as JS (no JSX allowed)
78 | - `.jsx` as JSX
79 | - `.ts` as TS (no TSX allowed)
80 | - `.tsx` as TSX
81 |
82 |
83 | If you want to force a specific loader on different file extensions (e.g. to allow JSX in `.js` files), you can use the [`loader` option](https://github.com/privatenumber/esbuild-loader/#loader):
84 |
85 | ```diff
86 | {
87 | test: /\.js$/,
88 | loader: 'esbuild-loader',
89 | options: {
90 | + // Treat `.js` files as `.jsx` files
91 | + loader: 'jsx',
92 |
93 | // JavaScript version to transpile to
94 | target: 'es2015'
95 | }
96 | }
97 | ```
98 |
99 |
100 | ## Loader
101 |
102 | ### JavaScript
103 |
104 | `esbuild-loader` can be used in-place of `babel-loader` to transpile new JavaScript syntax into code compatible with older JavaScript engines.
105 |
106 | While this ensures your code can run smoothly across various environments, note that it can bloat your output code (like Babel).
107 |
108 | The default target is `esnext`, which means it doesn't perform any transpilations.
109 |
110 | To specify a target JavaScript engine that only supports ES2015, use the following configuration in your `webpack.config.js`:
111 |
112 | ```diff
113 | {
114 | test: /\.jsx?$/,
115 | loader: 'esbuild-loader',
116 | options: {
117 | + target: 'es2015',
118 | },
119 | }
120 | ```
121 |
122 | For a detailed list of supported transpilations and versions, refer to [the esbuild documentation](https://esbuild.github.io/content-types/#javascript).
123 |
124 | ### TypeScript
125 |
126 | `esbuild-loader` can be used in-place of `ts-loader` to compile TypeScript.
127 |
128 | ```json5
129 | {
130 | // `.ts` or `.tsx` files
131 | test: /\.tsx?$/,
132 | loader: 'esbuild-loader',
133 | }
134 | ```
135 |
136 |
137 | > [!IMPORTANT]
138 | > It's possible to use `loader: 'tsx'` for both `.ts` and `.tsx` files, but this could lead to unexpected behavior as TypeScript and TSX do not have compatible syntaxes.
139 | >
140 | > [→ Read more](https://esbuild.github.io/content-types/#ts-vs-tsx)
141 |
142 | #### `tsconfig.json`
143 | If you have a `tsconfig.json` file in your project, `esbuild-loader` will automatically load it.
144 |
145 | If it's under a custom name, you can pass in the path via `tsconfig` option:
146 | ```diff
147 | {
148 | test: /\.tsx?$/,
149 | loader: 'esbuild-loader',
150 | options: {
151 | + tsconfig: './tsconfig.custom.json',
152 | },
153 | },
154 | ```
155 |
156 | > Behind the scenes: [`get-tsconfig`](https://github.com/privatenumber/get-tsconfig) is used to load the tsconfig, and to also resolve the `extends` property if it exists.
157 |
158 | The `tsconfigRaw` option can be used to pass in a raw `tsconfig` object, but it will not resolve the `extends` property.
159 |
160 |
161 | ##### Caveats
162 | - esbuild only supports a subset of `tsconfig` options [(see `TransformOptions` interface)](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L159-L165).
163 |
164 | - Enable [`isolatedModules`](https://www.typescriptlang.org/tsconfig#isolatedModules) to avoid mis-compilation with features like re-exporting types.
165 |
166 | - Enable [`esModuleInterop`](https://www.typescriptlang.org/tsconfig/#esModuleInterop) to make TypeScript's type system compatible with ESM imports.
167 |
168 | - Features that require type interpretation, such as `emitDecoratorMetadata` and declaration, are not supported.
169 |
170 | [→ Read more about TypeScript Caveats](https://esbuild.github.io/content-types/#typescript-caveats)
171 |
172 | #### `tsconfig.json` Paths
173 | Use [tsconfig-paths-webpack-plugin](https://github.com/dividab/tsconfig-paths-webpack-plugin) to add support for [`tsconfig.json#paths`](https://www.typescriptlang.org/tsconfig/paths.html).
174 |
175 | Since `esbuild-loader` only transforms code, it cannot aid Webpack with resolving paths.
176 |
177 |
178 | #### Type-checking
179 |
180 | esbuild **does not** type check your code. And according to the [esbuild FAQ](https://esbuild.github.io/faq/#:~:text=typescript%20type%20checking%20(just%20run%20tsc%20separately)), it will not be supported.
181 |
182 | Consider these type-checking alternatives:
183 | - Using an IDEs like [VSCode](https://code.visualstudio.com/docs/languages/typescript) or [WebStorm](https://www.jetbrains.com/help/webstorm/typescript-support.html) that has live type-checking built in
184 | - Running `tsc --noEmit` to type check
185 | - Integrating type-checking to your Webpack build as a separate process using [`fork-ts-checker-webpack-plugin`](https://github.com/TypeStrong/fork-ts-checker-webpack-plugin)
186 |
187 | ## EsbuildPlugin
188 |
189 | ### Minification
190 | Esbuild supports JavaScript minification, offering a faster alternative to traditional JS minifiers like Terser or UglifyJs. Minification is crucial for reducing file size and improving load times in web development. For a comparative analysis of its performance, refer to these [minification benchmarks](https://github.com/privatenumber/minification-benchmarks).
191 |
192 | In `webpack.config.js`:
193 |
194 | ```diff
195 | + const { EsbuildPlugin } = require('esbuild-loader')
196 |
197 | module.exports = {
198 | ...,
199 |
200 | + optimization: {
201 | + minimizer: [
202 | + new EsbuildPlugin({
203 | + target: 'es2015' // Syntax to transpile to (see options below for possible values)
204 | + })
205 | + ]
206 | + },
207 | }
208 | ```
209 |
210 | > [!TIP]
211 | > Utilizing the `target` option allows for the use of newer JavaScript syntax, enhancing minification effectiveness.
212 |
213 | ### Defining constants
214 |
215 | Webpack's [`DefinePlugin`](https://webpack.js.org/plugins/define-plugin/) can replaced with `EsbuildPlugin` to define global constants. This could speed up the build by removing the parsing costs associated with the `DefinePlugin`.
216 |
217 | In `webpack.config.js`:
218 |
219 | ```diff
220 | - const { DefinePlugin } = require('webpack')
221 | + const { EsbuildPlugin } = require('esbuild-loader')
222 |
223 | module.exports = {
224 | // ...,
225 |
226 | plugins:[
227 | - new DefinePlugin({
228 | - 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
229 | - })
230 | + new EsbuildPlugin({
231 | + define: {
232 | + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
233 | + },
234 | + }),
235 | ]
236 | }
237 | ```
238 |
239 | ### Transpilation
240 |
241 | If your project does not use TypeScript, JSX, or any other syntax that requires additional configuration beyond what Webpack provides, you can use `EsbuildPlugin` for transpilation instead of the loader.
242 |
243 | It will be faster because there's fewer files to process, and will produce a smaller output because polyfills will only be added once for the entire build as opposed to per file.
244 |
245 | To utilize esbuild for transpilation, simply set the `target` option on the plugin to specify which syntax support you want.
246 |
247 |
248 | ## CSS Minification
249 |
250 | Depending on your setup, there are two ways to minify CSS. You should already have CSS loading setup using [`css-loader`](https://github.com/webpack-contrib/css-loader).
251 |
252 | ### CSS assets
253 | If the CSS is extracted and emitted as `.css` file, you can replace CSS minification plugins like [`css-minimizer-webpack-plugin`](https://github.com/webpack-contrib/css-minimizer-webpack-plugin) with the `EsbuildPlugin`.
254 |
255 | Assuming the CSS is extracted using something like [MiniCssExtractPlugin](https://github.com/webpack-contrib/mini-css-extract-plugin), in `webpack.config.js`:
256 |
257 | ```diff
258 | const { EsbuildPlugin } = require('esbuild-loader')
259 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
260 |
261 | module.exports = {
262 | // ...,
263 |
264 | optimization: {
265 | minimizer: [
266 | new EsbuildPlugin({
267 | target: 'es2015',
268 | + css: true // Apply minification to CSS assets
269 | })
270 | ]
271 | },
272 |
273 | module: {
274 | rules: [
275 | {
276 | test: /\.css$/i,
277 | use: [
278 | MiniCssExtractPlugin.loader,
279 | 'css-loader'
280 | ]
281 | }
282 | ],
283 | },
284 |
285 | plugins: [
286 | new MiniCssExtractPlugin()
287 | ]
288 | }
289 | ```
290 |
291 |
292 | ### CSS in JS
293 |
294 | If your CSS is not emitted as a `.css` file, but rather injected with JavaScript using something like [`style-loader`](https://github.com/webpack-contrib/style-loader), you can use the loader for minification.
295 |
296 |
297 | In `webpack.config.js`:
298 |
299 | ```diff
300 | module.exports = {
301 | // ...,
302 |
303 | module: {
304 | rules: [
305 | {
306 | test: /\.css$/i,
307 | use: [
308 | 'style-loader',
309 | 'css-loader',
310 | + {
311 | + loader: 'esbuild-loader',
312 | + options: {
313 | + minify: true,
314 | + },
315 | + },
316 | ],
317 | },
318 | ],
319 | },
320 | }
321 | ```
322 |
323 | ## Bring your own esbuild (Advanced)
324 |
325 | esbuild-loader comes with a version of esbuild it has been tested to work with. However, [esbuild has a frequent release cadence](https://github.com/evanw/esbuild/releases), and while we try to keep up with the important releases, it can get outdated.
326 |
327 | To work around this, you can use the `implementation` option in the loader or the plugin to pass in your own version of esbuild (eg. a newer one).
328 |
329 | > [!WARNING]
330 | > ⚠esbuild is not stable yet and can have dramatic differences across releases. Using a different version of esbuild is not guaranteed to work.
331 |
332 |
333 | ```diff
334 | + const esbuild = require('esbuild')
335 |
336 | module.exports = {
337 | // ...,
338 |
339 | module: {
340 | rules: [
341 | {
342 | test: ...,
343 | loader: 'esbuild-loader',
344 | options: {
345 | // ...,
346 | + implementation: esbuild,
347 | },
348 | },
349 | ],
350 | },
351 | }
352 | ```
353 |
354 | ## Setup examples
355 | If you'd like to see working Webpack builds that use esbuild-loader for basic JS, React, TypeScript, Next.js, etc. check out the examples repo:
356 |
357 | [→ esbuild-loader examples](https://github.com/privatenumber/esbuild-loader-examples)
358 |
359 | ## ⚙️ Options
360 |
361 | ### Loader
362 | The loader supports [all Transform options from esbuild](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L158-L172).
363 |
364 | Note:
365 | - Source-maps are automatically configured for you via [`devtool`](https://webpack.js.org/configuration/devtool/). `sourcemap`/`sourcefile` options are ignored.
366 | - The root `tsconfig.json` is automatically detected for you. You don't need to pass in [`tsconfigRaw`](https://esbuild.github.io/api/#tsconfig-raw) unless it's in a different path.
367 |
368 |
369 | Here are some common configurations and custom options:
370 |
371 | #### tsconfig
372 |
373 | Type: `string`
374 |
375 | Pass in the file path to a **custom** tsconfig file. If the file name is `tsconfig.json`, it will automatically detect it.
376 |
377 | #### target
378 | Type: `string | Array`
379 |
380 | Default: `'es2015'`
381 |
382 | The target environment (e.g. `es2016`, `chrome80`, `esnext`).
383 |
384 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#target).
385 |
386 | #### loader
387 | Type: `'js' | 'jsx' | 'ts' | 'tsx' | 'css' | 'json' | 'text' | 'base64' | 'file' | 'dataurl' | 'binary' | 'default'`
388 |
389 | Default: `'default'`
390 |
391 | The loader to use to handle the file. See the type for [possible values](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L3).
392 |
393 | By default, it automatically detects the loader based on the file extension.
394 |
395 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#loader).
396 |
397 | #### jsxFactory
398 | Type: `string`
399 |
400 | Default: `React.createElement`
401 |
402 | Customize the JSX factory function name to use.
403 |
404 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#jsx-factory).
405 |
406 | #### jsxFragment
407 | Type: `string`
408 |
409 | Default: `React.Fragment`
410 |
411 | Customize the JSX fragment function name to use.
412 |
413 |
414 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#jsx-fragment).
415 |
416 | #### implementation
417 | Type: `{ transform: Function }`
418 |
419 | _Custom esbuild-loader option._
420 |
421 | Use it to pass in a [different esbuild version](#bring-your-own-esbuild-advanced).
422 |
423 | ### EsbuildPlugin
424 |
425 | The loader supports [all Transform options from esbuild](https://github.com/evanw/esbuild/blob/88821b7e7d46737f633120f91c65f662eace0bcf/lib/shared/types.ts#L158-L172).
426 |
427 | #### target
428 | Type: `string | Array`
429 |
430 | Default: `'esnext'`
431 |
432 | Target environment (e.g. `'es2016'`, `['chrome80', 'esnext']`)
433 |
434 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#target).
435 |
436 | Here are some common configurations and custom options:
437 |
438 | #### format
439 | Type: `'iife' | 'cjs' | 'esm'`
440 |
441 | Default:
442 | - `iife` if both of these conditions are met:
443 | - Webpack's [`target`](https://webpack.js.org/configuration/target/) is set to `web`
444 | - esbuild's [`target`](#target-1) is not `esnext`
445 | - `undefined` (no format conversion) otherwise
446 |
447 | The default is `iife` when esbuild is configured to support a low target, because esbuild injects helper functions at the top of the code. On the web, having functions declared at the top of a script can pollute the global scope. In some cases, this can lead to a variable collision error. By setting `format: 'iife'`, esbuild wraps the helper functions in an [IIFE](https://developer.mozilla.org/en-US/docs/Glossary/IIFE) to prevent them from polluting the global.
448 |
449 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#format).
450 |
451 | #### minify
452 | Type: `boolean`
453 |
454 | Default: `true`
455 |
456 | Enable JS minification. Enables all `minify*` flags below.
457 |
458 | To have nuanced control over minification, disable this and enable the specific minification you want below.
459 |
460 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#minify).
461 |
462 | #### minifyWhitespace
463 | Type: `boolean`
464 |
465 | Minify JS by removing whitespace.
466 |
467 | #### minifyIdentifiers
468 | Type: `boolean`
469 |
470 | Minify JS by shortening identifiers.
471 |
472 | #### minifySyntax
473 | Type: `boolean`
474 |
475 | Minify JS using equivalent but shorter syntax.
476 |
477 | #### legalComments
478 | Type: `'none' | 'inline' | 'eof' | 'external'`
479 |
480 | Default: `'inline'`
481 |
482 | Read more about it in the [esbuild docs](https://esbuild.github.io/api/#legal-comments).
483 |
484 | #### css
485 | Type: `boolean`
486 |
487 | Default: `false`
488 |
489 | Whether to minify CSS files.
490 |
491 | #### include
492 | Type: `string | RegExp | Array`
493 |
494 | To only apply the plugin to certain assets, pass in filters include
495 |
496 | #### exclude
497 | Type: `string | RegExp | Array`
498 |
499 | To prevent the plugin from applying to certain assets, pass in filters to exclude
500 |
501 | #### implementation
502 | Type: `{ transform: Function }`
503 |
504 | Use it to pass in a [different esbuild version](#bring-your-own-esbuild-advanced).
505 |
506 | ## 💡 Support
507 |
508 | For personalized assistance, take advantage of my [_Priority Support_ service](https://github.com/sponsors/privatenumber).
509 |
510 | Whether it's about Webpack configuration, esbuild, or TypeScript, I'm here to guide you every step of the way!
511 |
512 | ## 🙋♀️ FAQ
513 |
514 | ### Is it possible to use esbuild plugins?
515 | No. esbuild plugins are [only available in the build API](https://esbuild.github.io/plugins/#:~:text=plugins%20can%20also%20only%20be%20used%20with%20the%20build%20api%2C%20not%20with%20the%20transform%20api.). And esbuild-loader uses the transform API instead of the build API for two reasons:
516 | 1. The build API is for creating JS bundles, which is what Webpack does. If you want to use esbuild's build API, consider using esbuild directly instead of Webpack.
517 |
518 | 2. The build API reads directly from the file-system, but Webpack loaders operate in-memory. Webpack loaders are essentially just functions that are called with the source-code as the input. Not reading from the file-system allows loaders to be chainable. For example, using `vue-loader` to compile Single File Components (`.vue` files), then using `esbuild-loader` to transpile just the JS part of the SFC.
519 |
520 | ### Is it possible to use esbuild's [inject](https://esbuild.github.io/api/#inject) option?
521 |
522 | No. The `inject` option is only available in the build API. And esbuild-loader uses the transform API.
523 |
524 | However, you can use the Webpack equivalent [ProvidePlugin](https://webpack.js.org/plugins/provide-plugin/) instead.
525 |
526 | If you're using React, check out [this example](https://github.com/privatenumber/esbuild-loader-examples/blob/52ca91b8cb2080de5fc63cc6e9371abfefe1f823/examples/react/webpack.config.js#L39-L41) on how to auto-import React in your components.
527 |
528 | ### Is it possible to use Babel plugins?
529 | No. If you really need them, consider porting them over to a Webpack loader.
530 |
531 | And please don't chain `babel-loader` and `esbuild-loader`. The speed gains come from replacing `babel-loader`.
532 |
533 | ### Why am I not getting a [100x speed improvement](https://esbuild.github.io/faq/#benchmark-details) as advertised?
534 | Running esbuild as a standalone bundler vs esbuild-loader + Webpack are completely different:
535 | - esbuild is highly optimized, written in Go, and compiled to native code. Read more about it [here](https://esbuild.github.io/faq/#why-is-esbuild-fast).
536 | - esbuild-loader is handled by Webpack in a JS runtime, which applies esbuild transforms per file. On top of that, there's likely other loaders & plugins in a Webpack config that slow it down.
537 |
538 | Using a JS runtime introduces a bottleneck that makes reaching those speeds impossible. However, esbuild-loader can still speed up your build by removing the bottlenecks created by [`babel-loader`](https://twitter.com/wSokra/status/1316274855042584577), `ts-loader`, Terser, etc.
539 |
540 |
541 | ## 💞 Related projects
542 |
543 | #### [tsx](https://github.com/esbuild-kit/tsx)
544 | Node.js enhanced with esbuild to run TypeScript and ESM.
545 |
546 | #### [instant-mocha](https://github.com/privatenumber/instant-mocha)
547 | Webpack-integrated Mocha test-runner with Webpack 5 support.
548 |
549 | #### [webpack-localize-assets-plugin](https://github.com/privatenumber/webpack-localize-assets-plugin)
550 | Localize/i18nalize your Webpack build. Optimized for multiple locales!
551 |
552 | ## Sponsors
553 |
554 |
555 |
556 |
557 |
558 |
559 |
560 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "esbuild-loader",
3 | "version": "0.0.0-semantic-release",
4 | "description": "⚡️ Speed up your Webpack build with esbuild",
5 | "keywords": [
6 | "esbuild",
7 | "webpack",
8 | "loader",
9 | "typescript",
10 | "esnext"
11 | ],
12 | "license": "MIT",
13 | "repository": "privatenumber/esbuild-loader",
14 | "funding": "https://github.com/privatenumber/esbuild-loader?sponsor=1",
15 | "author": {
16 | "name": "Hiroki Osame",
17 | "email": "hiroki.osame@gmail.com"
18 | },
19 | "files": [
20 | "dist"
21 | ],
22 | "type": "module",
23 | "main": "./dist/index.cjs",
24 | "types": "./dist/index.d.cts",
25 | "exports": {
26 | ".": {
27 | "types": "./dist/index.d.cts",
28 | "default": "./dist/index.cjs"
29 | },
30 | "./package.json": "./package.json"
31 | },
32 | "imports": {
33 | "#esbuild-loader": {
34 | "types": "./src/index.d.ts",
35 | "development": "./src/index.ts",
36 | "default": "./dist/index.cjs"
37 | }
38 | },
39 | "scripts": {
40 | "build": "pkgroll --target=node16.19.0",
41 | "test": "tsx --env-file=.env tests",
42 | "dev": "tsx watch --env-file=.env --conditions=development tests",
43 | "lint": "lintroll --cache .",
44 | "type-check": "tsc --noEmit",
45 | "prepack": "pnpm build && clean-pkg-json"
46 | },
47 | "peerDependencies": {
48 | "webpack": "^4.40.0 || ^5.0.0"
49 | },
50 | "dependencies": {
51 | "esbuild": "^0.25.0",
52 | "get-tsconfig": "^4.7.0",
53 | "loader-utils": "^2.0.4",
54 | "webpack-sources": "^1.4.3"
55 | },
56 | "devDependencies": {
57 | "@types/loader-utils": "^2.0.3",
58 | "@types/mini-css-extract-plugin": "2.4.0",
59 | "@types/node": "^18.13.0",
60 | "@types/webpack": "^4.41.33",
61 | "@types/webpack-sources": "^0.1.9",
62 | "clean-pkg-json": "^1.2.0",
63 | "css-loader": "^5.2.7",
64 | "execa": "^8.0.1",
65 | "fs-fixture": "^2.4.0",
66 | "lintroll": "^1.6.1",
67 | "manten": "^1.3.0",
68 | "memfs": "^4.9.3",
69 | "mini-css-extract-plugin": "^1.6.2",
70 | "pkgroll": "^2.1.1",
71 | "tsx": "^4.15.6",
72 | "typescript": "^5.4.5",
73 | "webpack": "^4.44.2",
74 | "webpack-cli": "^4.10.0",
75 | "webpack-merge": "^5.9.0",
76 | "webpack-test-utils": "^2.1.0",
77 | "webpack5": "npm:webpack@^5.0.0"
78 | },
79 | "pnpm": {
80 | "overrides": {
81 | "fsevents@1": "^2.0.0"
82 | }
83 | },
84 | "packageManager": "pnpm@9.2.0"
85 | }
86 |
--------------------------------------------------------------------------------
/src/@types/webpack-module-filename-helpers.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'webpack/lib/ModuleFilenameHelpers.js' {
2 | type Filter = string | RegExp;
3 | type FilterObject = {
4 | test?: Filter | Filter[];
5 | include?: Filter | Filter[];
6 | exclude?: Filter | Filter[];
7 | };
8 |
9 | export const matchObject: (filterObject: FilterObject, stringToCheck: string) => boolean;
10 | }
11 |
--------------------------------------------------------------------------------
/src/@types/webpack.d.ts:
--------------------------------------------------------------------------------
1 | import 'webpack';
2 | import type { LoaderContext as Webpack5LoaderContext } from 'webpack5';
3 |
4 | declare module 'webpack' {
5 |
6 | namespace compilation {
7 | interface Compilation {
8 | getAssets(): Asset[];
9 | emitAsset(
10 | file: string,
11 | source: Source,
12 | assetInfo?: AssetInfo,
13 | ): void;
14 | }
15 | }
16 |
17 | namespace loader {
18 | interface LoaderContext {
19 | getOptions: Webpack5LoaderContext['getOptions'];
20 | }
21 | }
22 |
23 | interface AssetInfo {
24 | minimized?: boolean;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/index.d.ts:
--------------------------------------------------------------------------------
1 | import { EsbuildPluginOptions } from './types.js';
2 |
3 | export class EsbuildPlugin {
4 | constructor(options?: EsbuildPluginOptions);
5 |
6 | apply(): void;
7 | }
8 |
9 | export * from './types.js';
10 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import esbuildLoader from './loader.js';
2 | import EsbuildPlugin from './plugin.js';
3 |
4 | export default esbuildLoader;
5 | export { EsbuildPlugin };
6 |
--------------------------------------------------------------------------------
/src/loader.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import {
3 | transform as defaultEsbuildTransform,
4 | type TransformOptions,
5 | } from 'esbuild';
6 | import { getOptions } from 'loader-utils';
7 | import webpack from 'webpack';
8 | import {
9 | getTsconfig,
10 | parseTsconfig,
11 | createFilesMatcher,
12 | type TsConfigResult,
13 | } from 'get-tsconfig';
14 | import type { LoaderOptions } from './types.js';
15 |
16 | const tsconfigCache = new Map();
17 |
18 | const tsExtensionsPattern = /\.(?:[cm]?ts|[tj]sx)$/;
19 |
20 | async function ESBuildLoader(
21 | this: webpack.loader.LoaderContext,
22 | source: string,
23 | ): Promise {
24 | const done = this.async()!;
25 | const options: LoaderOptions = typeof this.getOptions === 'function' ? this.getOptions() : getOptions(this);
26 | const {
27 | implementation,
28 | tsconfig: tsconfigPath,
29 | ...esbuildTransformOptions
30 | } = options;
31 |
32 | if (implementation && typeof implementation.transform !== 'function') {
33 | done(
34 | new TypeError(
35 | `esbuild-loader: options.implementation.transform must be an ESBuild transform function. Received ${typeof implementation.transform}`,
36 | ),
37 | );
38 | return;
39 | }
40 | const transform = implementation?.transform ?? defaultEsbuildTransform;
41 |
42 | const { resourcePath } = this;
43 | const transformOptions = {
44 | ...esbuildTransformOptions,
45 | target: options.target ?? 'es2015',
46 | loader: options.loader ?? 'default',
47 | sourcemap: this.sourceMap,
48 | sourcefile: resourcePath,
49 | };
50 |
51 | const isDependency = resourcePath.includes(`${path.sep}node_modules${path.sep}`);
52 | if (
53 | !('tsconfigRaw' in transformOptions)
54 |
55 | // If file is local project, always try to apply tsconfig.json (e.g. allowJs)
56 | // If file is dependency, only apply tsconfig.json if .ts
57 | && (!isDependency || tsExtensionsPattern.test(resourcePath))
58 | ) {
59 | /**
60 | * If a tsconfig.json path is specified, force apply it
61 | * Same way a provided tsconfigRaw is applied regardless
62 | * of whether it actually matches
63 | *
64 | * However in this case, we also warn if it doesn't match
65 | */
66 | if (!isDependency && tsconfigPath) {
67 | const tsconfigFullPath = path.resolve(tsconfigPath);
68 | const cacheKey = `esbuild-loader:${tsconfigFullPath}`;
69 | let tsconfig = tsconfigCache.get(cacheKey);
70 | if (!tsconfig) {
71 | tsconfig = {
72 | config: parseTsconfig(tsconfigFullPath, tsconfigCache),
73 | path: tsconfigFullPath,
74 | };
75 | tsconfigCache.set(cacheKey, tsconfig);
76 | }
77 |
78 | const filesMatcher = createFilesMatcher(tsconfig);
79 | const matches = filesMatcher(resourcePath);
80 |
81 | if (!matches) {
82 | this.emitWarning(
83 | new Error(`esbuild-loader] The specified tsconfig at "${tsconfigFullPath}" was applied to the file "${resourcePath}" but does not match its "include" patterns`),
84 | );
85 | }
86 |
87 | transformOptions.tsconfigRaw = tsconfig.config as TransformOptions['tsconfigRaw'];
88 | } else {
89 | /* Detect tsconfig file */
90 |
91 | let tsconfig;
92 |
93 | try {
94 | // Webpack shouldn't be loading the same path multiple times so doesn't need to be cached
95 | tsconfig = getTsconfig(resourcePath, 'tsconfig.json', tsconfigCache);
96 | } catch (error) {
97 | if (error instanceof Error) {
98 | const tsconfigError = new Error(`[esbuild-loader] Error parsing tsconfig.json:\n${error.message}`);
99 | if (isDependency) {
100 | this.emitWarning(tsconfigError);
101 | } else {
102 | return done(tsconfigError);
103 | }
104 | }
105 | }
106 |
107 | if (tsconfig) {
108 | const fileMatcher = createFilesMatcher(tsconfig);
109 | transformOptions.tsconfigRaw = fileMatcher(resourcePath) as TransformOptions['tsconfigRaw'];
110 | }
111 | }
112 | }
113 |
114 | /**
115 | * Enable dynamic import by default to support code splitting in Webpack
116 | */
117 | transformOptions.supported = {
118 | 'dynamic-import': true,
119 | ...transformOptions.supported,
120 | };
121 |
122 | try {
123 | const { code, map } = await transform(source, transformOptions);
124 | done(null, code, map && JSON.parse(map));
125 | } catch (error: unknown) {
126 | done(error as Error);
127 | }
128 | }
129 |
130 | export default ESBuildLoader;
131 |
--------------------------------------------------------------------------------
/src/plugin.ts:
--------------------------------------------------------------------------------
1 | import { transform as defaultEsbuildTransform } from 'esbuild';
2 | import {
3 | RawSource as WP4RawSource,
4 | SourceMapSource as WP4SourceMapSource,
5 | } from 'webpack-sources';
6 | import type webpack4 from 'webpack';
7 | import type webpack5 from 'webpack5';
8 | import ModuleFilenameHelpers from 'webpack/lib/ModuleFilenameHelpers.js';
9 | import { version } from '../package.json';
10 | import type { EsbuildPluginOptions } from './types.js';
11 |
12 | type Compiler = webpack4.Compiler | webpack5.Compiler;
13 | type Compilation = webpack4.compilation.Compilation | webpack5.Compilation;
14 | type Asset = webpack4.compilation.Asset | Readonly;
15 | type EsbuildTransform = typeof defaultEsbuildTransform;
16 |
17 | const isJsFile = /\.[cm]?js(?:\?.*)?$/i;
18 | const isCssFile = /\.css(?:\?.*)?$/i;
19 | const pluginName = 'EsbuildPlugin';
20 |
21 | const transformAssets = async (
22 | options: EsbuildPluginOptions,
23 | transform: EsbuildTransform,
24 | compilation: Compilation,
25 | useSourceMap: boolean,
26 | ) => {
27 | const { compiler } = compilation;
28 | const sources = 'webpack' in compiler && compiler.webpack.sources;
29 | const SourceMapSource = (sources ? sources.SourceMapSource : WP4SourceMapSource);
30 | const RawSource = (sources ? sources.RawSource : WP4RawSource);
31 |
32 | const {
33 | css: minifyCss,
34 | include,
35 | exclude,
36 | implementation,
37 | ...transformOptions
38 | } = options;
39 |
40 | const minimized = (
41 | transformOptions.minify
42 | || transformOptions.minifyWhitespace
43 | || transformOptions.minifyIdentifiers
44 | || transformOptions.minifySyntax
45 | );
46 |
47 | const assets = (compilation.getAssets() as Asset[]).filter(asset => (
48 |
49 | // Filter out already minimized
50 | !asset.info.minimized
51 |
52 | // Filter out by file type
53 | && (
54 | isJsFile.test(asset.name)
55 | || (minifyCss && isCssFile.test(asset.name))
56 | )
57 | && ModuleFilenameHelpers.matchObject(
58 | {
59 | include,
60 | exclude,
61 | },
62 | asset.name,
63 | )
64 | ));
65 |
66 | await Promise.all(assets.map(async (asset) => {
67 | const assetIsCss = isCssFile.test(asset.name);
68 | let source: string | Buffer | ArrayBuffer;
69 | let map = null;
70 | if (useSourceMap) {
71 | if (asset.source.sourceAndMap) {
72 | const sourceAndMap = asset.source.sourceAndMap();
73 | source = sourceAndMap.source;
74 | map = sourceAndMap.map;
75 | } else {
76 | source = asset.source.source();
77 | if (asset.source.map) {
78 | map = asset.source.map();
79 | }
80 | }
81 | } else {
82 | source = asset.source.source();
83 | }
84 | const sourceAsString = source.toString();
85 | const result = await transform(sourceAsString, {
86 | ...transformOptions,
87 | loader: (
88 | assetIsCss
89 | ? 'css'
90 | : transformOptions.loader
91 | ),
92 | sourcemap: useSourceMap,
93 | sourcefile: asset.name,
94 | });
95 |
96 | if (result.legalComments) {
97 | compilation.emitAsset(
98 | `${asset.name}.LEGAL.txt`,
99 | new RawSource(result.legalComments) as webpack5.sources.Source,
100 | );
101 | }
102 |
103 | compilation.updateAsset(
104 | asset.name,
105 | (
106 | // @ts-expect-error complex webpack union type for source
107 | result.map
108 | ? new SourceMapSource(
109 | result.code,
110 | asset.name,
111 | // @ts-expect-error it accepts strings
112 | result.map,
113 | sourceAsString,
114 | map,
115 | true,
116 | )
117 | : new RawSource(result.code)
118 | ),
119 | {
120 | ...asset.info,
121 | minimized,
122 | },
123 | );
124 | }));
125 | };
126 |
127 | export default class EsbuildPlugin {
128 | options: EsbuildPluginOptions;
129 |
130 | constructor(
131 | options: EsbuildPluginOptions = {},
132 | ) {
133 | const { implementation } = options;
134 | if (
135 | implementation
136 | && typeof implementation.transform !== 'function'
137 | ) {
138 | throw new TypeError(
139 | `[${pluginName}] implementation.transform must be an esbuild transform function. Received ${typeof implementation.transform}`,
140 | );
141 | }
142 |
143 | this.options = options;
144 | }
145 |
146 | apply(compiler: Compiler) {
147 | const {
148 | implementation,
149 | ...options
150 | } = this.options;
151 | const transform = implementation?.transform ?? defaultEsbuildTransform;
152 |
153 | if (!('format' in options)) {
154 | const { target } = compiler.options;
155 | const isWebTarget = (
156 | Array.isArray(target)
157 | ? target.includes('web')
158 | : target === 'web'
159 | );
160 | const wontGenerateHelpers = !options.target || (
161 | Array.isArray(options.target)
162 | ? (
163 | options.target.length === 1
164 | && options.target[0] === 'esnext'
165 | )
166 | : options.target === 'esnext'
167 | );
168 |
169 | if (isWebTarget && !wontGenerateHelpers) {
170 | options.format = 'iife';
171 | }
172 | }
173 |
174 | /**
175 | * Enable minification by default if used in the minimizer array
176 | * unless further specified in the options
177 | */
178 | const usedAsMinimizer = compiler.options.optimization?.minimizer?.includes?.(this);
179 | if (
180 | usedAsMinimizer
181 | && !(
182 | 'minify' in options
183 | || 'minifyWhitespace' in options
184 | || 'minifyIdentifiers' in options
185 | || 'minifySyntax' in options
186 | )
187 | ) {
188 | options.minify = compiler.options.optimization?.minimize;
189 | }
190 |
191 | compiler.hooks.compilation.tap(pluginName, (compilation) => {
192 | const meta = JSON.stringify({
193 | name: 'esbuild-loader',
194 | version,
195 | options,
196 | });
197 |
198 | compilation.hooks.chunkHash.tap(
199 | pluginName,
200 | (_, hash) => hash.update(meta),
201 | );
202 |
203 | /**
204 | * Check if sourcemaps are enabled
205 | * Webpack 4: https://github.com/webpack/webpack/blob/v4.46.0/lib/SourceMapDevToolModuleOptionsPlugin.js#L20
206 | * Webpack 5: https://github.com/webpack/webpack/blob/v5.75.0/lib/SourceMapDevToolModuleOptionsPlugin.js#LL27
207 | */
208 | let useSourceMap = false;
209 |
210 | /**
211 | * `finishModules` hook is called after all the `buildModule` hooks are called,
212 | * which is where the `useSourceMap` flag is set
213 | * https://webpack.js.org/api/compilation-hooks/#finishmodules
214 | */
215 | compilation.hooks.finishModules.tap(
216 | pluginName,
217 | (modules) => {
218 | const firstModule = (
219 | Array.isArray(modules)
220 | ? modules[0]
221 | : (modules as Set).values().next().value as webpack5.Module
222 | );
223 | if (firstModule) {
224 | useSourceMap = firstModule.useSourceMap;
225 | }
226 | },
227 | );
228 |
229 | // Webpack 5
230 | if ('processAssets' in compilation.hooks) {
231 | compilation.hooks.processAssets.tapPromise(
232 | {
233 | name: pluginName,
234 | // @ts-expect-error undefined on Function type
235 | stage: compilation.constructor.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
236 | additionalAssets: true,
237 | },
238 | () => transformAssets(options, transform, compilation, useSourceMap),
239 | );
240 |
241 | compilation.hooks.statsPrinter.tap(pluginName, (statsPrinter) => {
242 | statsPrinter.hooks.print
243 | .for('asset.info.minimized')
244 | .tap(
245 | pluginName,
246 | (
247 | minimized,
248 | { green, formatFlag },
249 | // @ts-expect-error type incorrectly doesn't accept undefined
250 | ) => (
251 | minimized
252 | // @ts-expect-error type incorrectly doesn't accept undefined
253 | ? green(formatFlag('minimized'))
254 | : undefined
255 | ),
256 | );
257 | });
258 | } else {
259 | compilation.hooks.optimizeChunkAssets.tapPromise(
260 | pluginName,
261 | () => transformAssets(options, transform, compilation, useSourceMap),
262 | );
263 | }
264 | });
265 | }
266 | }
267 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import { transform, type TransformOptions } from 'esbuild';
2 |
3 | type Filter = string | RegExp;
4 |
5 | type Implementation = {
6 | transform: typeof transform;
7 | };
8 |
9 | type Except = {
10 | [Key in keyof ObjectType as (Key extends Properties ? never : Key)]: ObjectType[Key];
11 | };
12 |
13 | export type LoaderOptions = Except & {
14 |
15 | /** Pass a custom esbuild implementation */
16 | implementation?: Implementation;
17 |
18 | /**
19 | * Path to tsconfig.json file
20 | */
21 | tsconfig?: string;
22 | };
23 |
24 | export type EsbuildPluginOptions = Except & {
25 | include?: Filter | Filter[];
26 | exclude?: Filter | Filter[];
27 | css?: boolean;
28 |
29 | /** Pass a custom esbuild implementation */
30 | implementation?: Implementation;
31 | };
32 |
--------------------------------------------------------------------------------
/tests/fixtures.ts:
--------------------------------------------------------------------------------
1 | export const exportFile = (
2 | name: string,
3 | code: string,
4 | ) => ({
5 | '/src/index.js': `export { default } from "./${name}"`,
6 | [`/src/${name}`]: code,
7 | });
8 |
9 | const trySyntax = (
10 | name: string,
11 | code: string,
12 | ) => `
13 | (() => {
14 | try {
15 | ${code}
16 | return ${JSON.stringify(name)};
17 | } catch (error) {
18 | return error;
19 | }
20 | })()
21 | `;
22 |
23 | export const js = exportFile(
24 | 'js.js',
25 | `export default [${[
26 | trySyntax(
27 | 'es2016 - Exponentiation operator',
28 | '10 ** 4',
29 | ),
30 |
31 | trySyntax(
32 | 'es2017 - Async functions',
33 | 'typeof (async () => {})',
34 | ),
35 |
36 | // trySyntax(
37 | // 'es2018 - Asynchronous iteration',
38 | // 'for await (let x of []) {}',
39 | // ),
40 |
41 | trySyntax(
42 | 'es2018 - Spread properties',
43 | 'let x = {...Object}',
44 | ),
45 |
46 | trySyntax(
47 | 'es2018 - Rest properties',
48 | 'let {...x} = Object',
49 | ),
50 |
51 | trySyntax(
52 | 'es2019 - Optional catch binding',
53 | 'try {} catch {}',
54 | ),
55 |
56 | trySyntax(
57 | 'es2020 - Optional chaining',
58 | 'Object?.keys',
59 | ),
60 |
61 | trySyntax(
62 | 'es2020 - Nullish coalescing',
63 | 'Object ?? true',
64 | ),
65 |
66 | trySyntax(
67 | 'es2020 - import.meta',
68 | 'import.meta',
69 | ),
70 |
71 | trySyntax(
72 | 'es2021 - Logical assignment operators',
73 | 'let a = false; a ??= true; a ||= true; a &&= true;',
74 | ),
75 |
76 | trySyntax(
77 | 'es2022 - Class instance fields',
78 | '(class { x })',
79 | ),
80 |
81 | trySyntax(
82 | 'es2022 - Static class fields',
83 | '(class { static x })',
84 | ),
85 |
86 | trySyntax(
87 | 'es2022 - Private instance methods',
88 | '(class { #x() {} })',
89 | ),
90 |
91 | trySyntax(
92 | 'es2022 - Private instance fields',
93 | '(class { #x })',
94 | ),
95 |
96 | trySyntax(
97 | 'es2022 - Private static methods',
98 | '(class { static #x() {} })',
99 | ),
100 |
101 | trySyntax(
102 | 'es2022 - Private static fields',
103 | '(class { static #x })',
104 | ),
105 |
106 | // trySyntax(
107 | // 'es2022 - Ergonomic brand checks',
108 | // '(class { #brand; static isC(obj) { return try obj.#brand; } })',
109 | // ),
110 |
111 | trySyntax(
112 | 'es2022 - Class static blocks',
113 | '(class { static {} })',
114 | ),
115 |
116 | // trySyntax(
117 | // 'esnext - Import assertions',
118 | // 'import "x" assert {}',
119 | // ),
120 |
121 | ].join(',')}];`,
122 | );
123 |
124 | export const ts = exportFile(
125 | 'ts.ts',
126 | `
127 | import type {Type} from 'foo'
128 |
129 | interface Foo {}
130 |
131 | type Foo = number
132 |
133 | declare module 'foo' {}
134 |
135 | enum BasicEnum {
136 | Left,
137 | Right,
138 | }
139 |
140 | enum NamedEnum {
141 | SomeEnum = 'some-value',
142 | }
143 |
144 | export const a = BasicEnum.Left;
145 |
146 | export const b = NamedEnum.SomeEnum;
147 |
148 | export default function foo(): string {
149 | return 'foo'
150 | }
151 |
152 | // For "ts as tsx" test
153 | const bar = (value: T) => fn();
154 | `,
155 | );
156 |
157 | export const blank = {
158 | '/src/index.js': '',
159 | };
160 |
161 | export const minification = {
162 | '/src/index.js': 'export default ( stringVal ) => { return stringVal }',
163 | };
164 |
165 | export const define = {
166 | '/src/index.js': 'export default () => [__TEST1__, __TEST2__]',
167 | };
168 |
169 | export const getHelpers = {
170 | '/src/index.js': 'export default async () => {}',
171 | };
172 |
173 | export const legalComments = {
174 | '/src/index.js': `
175 | //! legal comment
176 | globalCall();
177 | `,
178 | };
179 |
180 | export const css = {
181 | '/src/index.js': 'import "./styles.css"',
182 | '/src/styles.css': `
183 | div {
184 | color: red;
185 | }
186 | span {
187 | margin: 0px 10px;
188 | }
189 | `,
190 | };
191 |
--------------------------------------------------------------------------------
/tests/index.ts:
--------------------------------------------------------------------------------
1 | import { describe } from 'manten';
2 | import webpack4 from 'webpack';
3 | import webpack5 from 'webpack5';
4 |
5 | const webpacks = [
6 | webpack4,
7 | webpack5,
8 | ];
9 |
10 | describe('esbuild-loader', ({ describe, runTestSuite }) => {
11 | for (const webpack of webpacks) {
12 | describe(`Webpack ${webpack.version![0]}`, ({ runTestSuite }) => {
13 | runTestSuite(import('./specs/loader.js'), webpack);
14 | runTestSuite(import('./specs/plugin.js'), webpack);
15 | });
16 | }
17 |
18 | runTestSuite(import('./specs/tsconfig.js'));
19 | runTestSuite(import('./specs/webpack5.js'));
20 | });
21 |
--------------------------------------------------------------------------------
/tests/specs/loader.ts:
--------------------------------------------------------------------------------
1 | import { testSuite, expect } from 'manten';
2 | import { build } from 'webpack-test-utils';
3 | import webpack4 from 'webpack';
4 | import webpack5 from 'webpack5';
5 | import {
6 | type Webpack,
7 | configureEsbuildLoader,
8 | configureCssLoader,
9 | } from '../utils.js';
10 | import * as fixtures from '../fixtures.js';
11 | import type { EsbuildPluginOptions } from '#esbuild-loader';
12 |
13 | const { exportFile } = fixtures;
14 |
15 | export default testSuite(({ describe }, webpack: typeof webpack4 | typeof webpack5) => {
16 | describe('Loader', ({ test, describe }) => {
17 | describe('Error handling', ({ test }) => {
18 | test('tsx fails to be parsed as ts', async () => {
19 | const built = await build(
20 | exportFile(
21 | 'tsx.tsx',
22 | 'export default hello world
',
23 | ),
24 | (config) => {
25 | configureEsbuildLoader(config, {
26 | test: /\.tsx$/,
27 | options: {
28 | loader: 'ts',
29 | },
30 | });
31 | },
32 | webpack,
33 | );
34 |
35 | expect(built.stats.hasErrors()).toBe(true);
36 | const [error] = built.stats.compilation.errors;
37 | expect(error.message).toMatch('Transform failed with 1 error');
38 | });
39 | });
40 |
41 | test('transforms syntax', async () => {
42 | const built = await build(
43 | fixtures.js,
44 | configureEsbuildLoader,
45 | webpack,
46 | );
47 |
48 | expect(built.stats.hasWarnings()).toBe(false);
49 | expect(built.stats.hasErrors()).toBe(false);
50 | expect(built.require('/dist')).toStrictEqual([
51 | 'es2016 - Exponentiation operator',
52 | 'es2017 - Async functions',
53 | 'es2018 - Spread properties',
54 | 'es2018 - Rest properties',
55 | 'es2019 - Optional catch binding',
56 | 'es2020 - Optional chaining',
57 | 'es2020 - Nullish coalescing',
58 | 'es2020 - import.meta',
59 | 'es2021 - Logical assignment operators',
60 | 'es2022 - Class instance fields',
61 | 'es2022 - Static class fields',
62 | 'es2022 - Private instance methods',
63 | 'es2022 - Private instance fields',
64 | 'es2022 - Private static methods',
65 | 'es2022 - Private static fields',
66 | 'es2022 - Class static blocks',
67 | ]);
68 | });
69 |
70 | test('transforms TypeScript', async () => {
71 | const built = await build(
72 | fixtures.ts,
73 | (config) => {
74 | configureEsbuildLoader(config, {
75 | test: /\.ts$/,
76 | });
77 | },
78 | webpack,
79 | );
80 |
81 | expect(built.stats.hasWarnings()).toBe(false);
82 | expect(built.stats.hasErrors()).toBe(false);
83 |
84 | expect(built.require('/dist')()).toBe('foo');
85 | });
86 |
87 | test('transforms TSX', async () => {
88 | const built = await build(
89 | exportFile(
90 | 'tsx.tsx',
91 | 'export default (<>hello world
>)',
92 | ),
93 | (config) => {
94 | configureEsbuildLoader(config, {
95 | test: /\.tsx$/,
96 | options: {
97 | jsxFactory: 'Array',
98 | jsxFragment: '"Fragment"',
99 | },
100 | });
101 | },
102 | webpack,
103 | );
104 |
105 | expect(built.stats.hasWarnings()).toBe(false);
106 | expect(built.stats.hasErrors()).toBe(false);
107 |
108 | expect(built.require('/dist')).toStrictEqual([
109 | 'Fragment',
110 | null,
111 | [
112 | 'div',
113 | null,
114 | 'hello world',
115 | ],
116 | ]);
117 | });
118 |
119 | test('tsconfig', async () => {
120 | const built = await build(
121 | exportFile(
122 | 'tsx.tsx',
123 | 'export default (hello world
)',
124 | ),
125 | (config) => {
126 | configureEsbuildLoader(config, {
127 | test: /\.tsx$/,
128 | options: {
129 | tsconfigRaw: {
130 | compilerOptions: {
131 | jsxFactory: 'Array',
132 | },
133 | },
134 | },
135 | });
136 | },
137 | webpack,
138 | );
139 |
140 | expect(built.stats.hasWarnings()).toBe(false);
141 | expect(built.stats.hasErrors()).toBe(false);
142 | expect(built.require('/dist/index.js')).toStrictEqual(['div', null, 'hello world']);
143 | });
144 |
145 | describe('implementation', ({ test }) => {
146 | test('error', async () => {
147 | const runWithImplementation = async (
148 | implementation: EsbuildPluginOptions['implementation'],
149 | ) => {
150 | const built = await build(
151 | fixtures.blank,
152 | (config) => {
153 | configureEsbuildLoader(config, {
154 | options: {
155 | implementation,
156 | },
157 | });
158 | },
159 | webpack,
160 | );
161 |
162 | expect(built.stats.hasErrors()).toBe(true);
163 | const [error] = built.stats.compilation.errors;
164 | throw error;
165 | };
166 |
167 | // @ts-expect-error testing invalid type
168 | await expect(runWithImplementation({})).rejects.toThrow(
169 | 'esbuild-loader: options.implementation.transform must be an ESBuild transform function. Received undefined',
170 | );
171 |
172 | // @ts-expect-error testing invalid type
173 | await expect(runWithImplementation({ transform: 123 })).rejects.toThrow(
174 | 'esbuild-loader: options.implementation.transform must be an ESBuild transform function. Received number',
175 | );
176 | });
177 |
178 | test('custom transform function', async () => {
179 | const built = await build(
180 | fixtures.blank,
181 | (config) => {
182 | configureEsbuildLoader(config, {
183 | options: {
184 | implementation: {
185 | transform: async () => ({
186 | code: 'export default "CUSTOM_ESBUILD_IMPLEMENTATION"',
187 | map: '',
188 | warnings: [],
189 | }),
190 | },
191 | },
192 | });
193 | },
194 | webpack,
195 | );
196 |
197 | expect(built.stats.hasWarnings()).toBe(false);
198 | expect(built.stats.hasErrors()).toBe(false);
199 |
200 | const dist = built.fs.readFileSync('/dist/index.js', 'utf8');
201 | expect(dist).toContain('CUSTOM_ESBUILD_IMPLEMENTATION');
202 | });
203 | });
204 |
205 | describe('ambigious ts/tsx', () => {
206 | test('ts via tsx', async () => {
207 | const built = await build(
208 | fixtures.ts,
209 | (config) => {
210 | configureEsbuildLoader(config, {
211 | test: /\.tsx?$/,
212 | });
213 | },
214 | webpack,
215 | );
216 |
217 | expect(built.stats.hasWarnings()).toBe(false);
218 | expect(built.stats.hasErrors()).toBe(false);
219 |
220 | expect(built.require('/dist')()).toBe('foo');
221 | });
222 |
223 | test('ts via tsx 2', async () => {
224 | const built = await build(
225 | exportFile(
226 | 'ts.ts', `
227 | export default (
228 | l: obj,
229 | options: { [key in obj]: V },
230 | ): V => {
231 | return options[l];
232 | };
233 | `,
234 | ),
235 | (config) => {
236 | configureEsbuildLoader(config, {
237 | test: /\.tsx?$/,
238 | });
239 | },
240 | webpack,
241 | );
242 |
243 | expect(built.stats.hasWarnings()).toBe(false);
244 | expect(built.stats.hasErrors()).toBe(false);
245 |
246 | expect(built.require('/dist')('a', { a: 1 })).toBe(1);
247 | });
248 |
249 | test('ambiguous ts', async () => {
250 | const built = await build(
251 | exportFile(
252 | 'ts.ts',
253 | 'export default () => 1/g',
254 | ),
255 | (config) => {
256 | configureEsbuildLoader(config, {
257 | test: /\.tsx?$/,
258 | });
259 | },
260 | webpack,
261 | );
262 |
263 | expect(built.stats.hasWarnings()).toBe(false);
264 | expect(built.stats.hasErrors()).toBe(false);
265 |
266 | const dist = built.fs.readFileSync('/dist/index.js', 'utf8');
267 | expect(dist).toContain('(() => 1 < /a>/g)');
268 | });
269 |
270 | test('ambiguous tsx', async () => {
271 | const built = await build(
272 | exportFile(
273 | 'tsx.tsx',
274 | 'export default () => 1/g',
275 | ),
276 | (config) => {
277 | configureEsbuildLoader(config, {
278 | test: /\.tsx?$/,
279 | });
280 | },
281 | webpack,
282 | );
283 |
284 | expect(built.stats.hasWarnings()).toBe(false);
285 | expect(built.stats.hasErrors()).toBe(false);
286 |
287 | const dist = built.fs.readFileSync('/dist/index.js', 'utf8');
288 | expect(dist).toContain('React.createElement');
289 | });
290 | });
291 |
292 | describe('Source-map', ({ test }) => {
293 | test('source-map eval', async () => {
294 | const built = await build(
295 | fixtures.js,
296 | (config) => {
297 | configureEsbuildLoader(config);
298 | config.devtool = 'eval-source-map';
299 | },
300 | webpack,
301 | );
302 |
303 | expect(built.stats.hasWarnings()).toBe(false);
304 | expect(built.stats.hasErrors()).toBe(false);
305 |
306 | const dist = built.fs.readFileSync('/dist/index.js', 'utf8');
307 | expect(dist).toContain('eval(');
308 | });
309 |
310 | test('source-map inline', async () => {
311 | const built = await build(
312 | fixtures.js,
313 | (config) => {
314 | configureEsbuildLoader(config);
315 | config.devtool = 'inline-source-map';
316 | },
317 | webpack,
318 | );
319 |
320 | expect(built.stats.hasWarnings()).toBe(false);
321 | expect(built.stats.hasErrors()).toBe(false);
322 |
323 | const dist = built.fs.readFileSync('/dist/index.js', 'utf8');
324 | expect(dist).toContain('sourceMappingURL');
325 | });
326 |
327 | test('source-map file', async () => {
328 | const built = await build(
329 | fixtures.js,
330 | (config) => {
331 | configureEsbuildLoader(config);
332 | config.devtool = 'source-map';
333 | },
334 | webpack,
335 | );
336 |
337 | expect(built.stats.hasWarnings()).toBe(false);
338 | expect(built.stats.hasErrors()).toBe(false);
339 |
340 | const { assets } = built.stats.compilation;
341 | expect(assets).toHaveProperty(['index.js']);
342 | expect(assets).toHaveProperty(['index.js.map']);
343 | });
344 |
345 | test('source-map plugin', async () => {
346 | const built = await build(
347 | fixtures.js,
348 | (config) => {
349 | configureEsbuildLoader(config);
350 |
351 | delete config.devtool;
352 | config.plugins!.push(
353 | new webpack.SourceMapDevToolPlugin({}) as Webpack['SourceMapDevToolPlugin'],
354 | );
355 | },
356 | webpack,
357 | );
358 |
359 | expect(built.stats.hasWarnings()).toBe(false);
360 | expect(built.stats.hasErrors()).toBe(false);
361 |
362 | const dist = built.fs.readFileSync('/dist/index.js', 'utf8');
363 | expect(dist).toContain('sourceMappingURL');
364 | });
365 | });
366 |
367 | test('webpack magic comments', async () => {
368 | const built = await build({
369 | '/src/index.js': `
370 | const chunkA = import(/* webpackChunkName: "named-chunk-foo" */'./chunk-a.js')
371 | const chunkB = import(/* webpackChunkName: "named-chunk-bar" */'./chunk-b.js')
372 | export default async () => (await chunkA).default + (await chunkB).default;
373 | `,
374 | '/src/chunk-a.js': 'export default 1',
375 | '/src/chunk-b.js': 'export default 2',
376 | }, configureEsbuildLoader, webpack);
377 |
378 | expect(built.stats.hasWarnings()).toBe(false);
379 | expect(built.stats.hasErrors()).toBe(false);
380 |
381 | const { assets } = built.stats.compilation;
382 | expect(assets).toHaveProperty(['index.js']);
383 | expect(assets).toHaveProperty(['named-chunk-foo.js']);
384 | expect(assets).toHaveProperty(['named-chunk-bar.js']);
385 | expect(await built.require('/dist')()).toBe(3);
386 | });
387 |
388 | test('CSS minification', async () => {
389 | const built = await build(
390 | fixtures.css,
391 | (config) => {
392 | configureEsbuildLoader(config);
393 | const cssRule = configureCssLoader(config);
394 | cssRule.use.push({
395 | loader: 'esbuild-loader',
396 | options: {
397 | minify: true,
398 | },
399 | });
400 | },
401 | webpack,
402 | );
403 |
404 | expect(built.stats.hasWarnings()).toBe(false);
405 | expect(built.stats.hasErrors()).toBe(false);
406 |
407 | const code = built.fs.readFileSync('/dist/index.js', 'utf8');
408 | expect(code).toContain('div{color:red}');
409 | });
410 |
411 | test('Keeps dynamic imports by default', async () => {
412 | const built = await build(
413 | {
414 | '/src/index.js': 'export default async () => (await import("./test2.js")).default',
415 | '/src/test2.js': 'export default "test2"',
416 | },
417 | (config) => {
418 | configureEsbuildLoader(config, { options: { target: 'chrome52' } });
419 | },
420 | webpack,
421 | );
422 |
423 | expect(built.stats.hasWarnings()).toBe(false);
424 | expect(built.stats.hasErrors()).toBe(false);
425 |
426 | const { assets } = built.stats.compilation;
427 | expect(assets).toHaveProperty(['index.js']);
428 |
429 | // Chunk split because esbuild preserved the dynamic import
430 | expect(Object.keys(assets).length).toBe(2);
431 | expect(await built.require('/dist')()).toBe('test2');
432 | });
433 |
434 | test('Dynamic imports can be disabled', async () => {
435 | const built = await build(
436 | {
437 | '/src/index.js': 'export default async () => (await import("./test2.js")).default',
438 | '/src/test2.js': 'export default "test2"',
439 | },
440 | (config) => {
441 | configureEsbuildLoader(config, {
442 | options: {
443 | target: 'chrome52',
444 | supported: { 'dynamic-import': false },
445 | },
446 | });
447 | },
448 | webpack,
449 | );
450 |
451 | expect(built.stats.hasWarnings()).toBe(false);
452 | expect(built.stats.hasErrors()).toBe(false);
453 |
454 | const { assets } = built.stats.compilation;
455 | expect(assets).toHaveProperty(['index.js']);
456 |
457 | // No chunk split because esbuild removed the dynamic import
458 | expect(Object.keys(assets).length).toBe(1);
459 | expect(await built.require('/dist')()).toBe('test2');
460 | });
461 | });
462 | });
463 |
--------------------------------------------------------------------------------
/tests/specs/plugin.ts:
--------------------------------------------------------------------------------
1 | import { testSuite, expect } from 'manten';
2 | import { build } from 'webpack-test-utils';
3 | import webpack4 from 'webpack';
4 | import webpack5 from 'webpack5';
5 | import * as esbuild from 'esbuild';
6 | import { merge } from 'webpack-merge';
7 | import {
8 | type Webpack,
9 | isWebpack4,
10 | configureEsbuildMinifyPlugin,
11 | configureMiniCssExtractPlugin,
12 | } from '../utils.js';
13 | import * as fixtures from '../fixtures.js';
14 | import { EsbuildPlugin, type EsbuildPluginOptions } from '#esbuild-loader';
15 |
16 | const assertMinified = (code: string) => {
17 | expect(code).not.toMatch(/\s{2,}/);
18 | expect(code).not.toMatch('stringVal');
19 | expect(code).not.toMatch('return ');
20 | };
21 |
22 | const countIife = (code: string) => Array.from(code.matchAll(/\(\(\)=>\{/g)).length;
23 |
24 | export default testSuite(({ describe }, webpack: typeof webpack4 | typeof webpack5) => {
25 | const webpackIs4 = isWebpack4(webpack);
26 |
27 | describe('Plugin', ({ test, describe }) => {
28 | describe('Minify JS', ({ test, describe }) => {
29 | describe('should not minify by default', ({ test }) => {
30 | test('minimizer', async () => {
31 | const built = await build(
32 | fixtures.minification,
33 | (config) => {
34 | config.optimization = {
35 | minimize: false,
36 | minimizer: [
37 | new EsbuildPlugin(),
38 | ],
39 | };
40 | },
41 | webpack,
42 | );
43 |
44 | expect(built.stats.hasWarnings()).toBe(false);
45 | expect(built.stats.hasErrors()).toBe(false);
46 |
47 | const exportedFunction = built.require('/dist/');
48 | expect(exportedFunction('hello world')).toBe('hello world');
49 | expect(exportedFunction.toString()).toMatch(/\s{2,}/);
50 | });
51 |
52 | test('plugin', async () => {
53 | const built = await build(
54 | fixtures.minification,
55 | (config) => {
56 | config.plugins?.push(new EsbuildPlugin());
57 | },
58 | webpack,
59 | );
60 |
61 | expect(built.stats.hasWarnings()).toBe(false);
62 | expect(built.stats.hasErrors()).toBe(false);
63 |
64 | const exportedFunction = built.require('/dist/');
65 | expect(exportedFunction('hello world')).toBe('hello world');
66 | expect(exportedFunction.toString()).toMatch(/\s{2,}/);
67 | });
68 |
69 | test('plugin with minimize enabled', async () => {
70 | const built = await build(
71 | fixtures.minification,
72 | (config) => {
73 | config.optimization = {
74 | minimize: true,
75 |
76 | // Remove Terser
77 | minimizer: [],
78 | };
79 |
80 | config.plugins?.push(new EsbuildPlugin());
81 | },
82 | webpack,
83 | );
84 |
85 | expect(built.stats.hasWarnings()).toBe(false);
86 | expect(built.stats.hasErrors()).toBe(false);
87 |
88 | const exportedFunction = built.require('/dist/');
89 | expect(exportedFunction('hello world')).toBe('hello world');
90 | expect(exportedFunction.toString()).toMatch(/\s{2,}/);
91 | });
92 | });
93 |
94 | test('minify', async () => {
95 | const built = await build(
96 | fixtures.minification,
97 | (config) => {
98 | configureEsbuildMinifyPlugin(config);
99 | },
100 | webpack,
101 | );
102 |
103 | expect(built.stats.hasWarnings()).toBe(false);
104 | expect(built.stats.hasErrors()).toBe(false);
105 |
106 | const exportedFunction = built.require('/dist/');
107 | expect(exportedFunction('hello world')).toBe('hello world');
108 | assertMinified(exportedFunction.toString());
109 | });
110 |
111 | test('minifyWhitespace', async () => {
112 | const built = await build(
113 | fixtures.minification,
114 | (config) => {
115 | configureEsbuildMinifyPlugin(config, {
116 | minifyWhitespace: true,
117 | });
118 | },
119 | webpack,
120 | );
121 |
122 | expect(built.stats.hasWarnings()).toBe(false);
123 | expect(built.stats.hasErrors()).toBe(false);
124 |
125 | const exportedFunction = built.require('/dist/');
126 | expect(exportedFunction('hello world')).toBe('hello world');
127 |
128 | const code = exportedFunction.toString();
129 | expect(code).not.toMatch(/\s{2,}/);
130 | expect(code).toMatch('stringVal');
131 | expect(code).toMatch('return ');
132 | });
133 |
134 | test('minifyIdentifiers', async () => {
135 | const built = await build(
136 | fixtures.minification,
137 | (config) => {
138 | configureEsbuildMinifyPlugin(config, {
139 | minifyIdentifiers: true,
140 | });
141 | },
142 | webpack,
143 | );
144 |
145 | expect(built.stats.hasWarnings()).toBe(false);
146 | expect(built.stats.hasErrors()).toBe(false);
147 |
148 | const exportedFunction = built.require('/dist/');
149 | expect(exportedFunction('hello world')).toBe('hello world');
150 |
151 | const code = exportedFunction.toString();
152 | expect(code).toMatch(/\s{2,}/);
153 | expect(code).not.toMatch('stringVal');
154 | expect(code).toMatch('return ');
155 | });
156 |
157 | test('minifySyntax', async () => {
158 | const built = await build(
159 | fixtures.minification,
160 | (config) => {
161 | configureEsbuildMinifyPlugin(config, {
162 | minifySyntax: true,
163 | });
164 | },
165 | webpack,
166 | );
167 |
168 | expect(built.stats.hasWarnings()).toBe(false);
169 | expect(built.stats.hasErrors()).toBe(false);
170 |
171 | const exportedFunction = built.require('/dist/');
172 | expect(exportedFunction('hello world')).toBe('hello world');
173 |
174 | const code = exportedFunction.toString();
175 | expect(code).toMatch(/\s/);
176 | expect(code).toMatch('stringVal');
177 | expect(code).not.toMatch('return ');
178 | });
179 |
180 | test('should minify when used alongside plugin', async () => {
181 | const built = await build(
182 | fixtures.minification,
183 | (config) => {
184 | configureEsbuildMinifyPlugin(config);
185 | config.plugins?.push(new EsbuildPlugin());
186 | },
187 | webpack,
188 | );
189 |
190 | expect(built.stats.hasWarnings()).toBe(false);
191 | expect(built.stats.hasErrors()).toBe(false);
192 |
193 | const exportedFunction = built.require('/dist/');
194 | expect(exportedFunction('hello world')).toBe('hello world');
195 | assertMinified(exportedFunction.toString());
196 | });
197 |
198 | test('minify chunks & filter using include/exclude', async () => {
199 | const built = await build({
200 | '/src/index.js': `
201 | const foo = import(/* webpackChunkName: "named-chunk-foo" */'./foo.js')
202 | const bar = import(/* webpackChunkName: "named-chunk-bar" */'./bar.js')
203 | const baz = import(/* webpackChunkName: "named-chunk-baz" */'./baz.js')
204 | export default [foo, bar, baz];
205 | `,
206 | '/src/foo.js': fixtures.minification['/src/index.js'],
207 | '/src/bar.js': fixtures.minification['/src/index.js'],
208 | '/src/baz.js': fixtures.minification['/src/index.js'],
209 | }, (config) => {
210 | configureEsbuildMinifyPlugin(config, {
211 | include: /ba./,
212 | exclude: /baz/,
213 | });
214 | }, webpack);
215 |
216 | expect(built.stats.hasWarnings()).toBe(false);
217 | expect(built.stats.hasErrors()).toBe(false);
218 |
219 | const chunkFoo = built.fs.readFileSync('/dist/named-chunk-foo.js', 'utf8').toString();
220 |
221 | // The string "__webpack_require__" is only present in unminified chunks
222 | expect(chunkFoo).toContain('__webpack_require__');
223 |
224 | const chunkBar = built.fs.readFileSync('/dist/named-chunk-bar.js', 'utf8').toString();
225 | expect(chunkBar).not.toContain('__webpack_require__');
226 | assertMinified(chunkBar);
227 |
228 | const chunkBaz = built.fs.readFileSync('/dist/named-chunk-baz.js', 'utf8').toString();
229 | expect(chunkBaz).toContain('__webpack_require__');
230 | });
231 |
232 | describe('devtool', ({ test }) => {
233 | test('minify w/ no devtool', async () => {
234 | const built = await build(
235 | fixtures.blank,
236 | (config) => {
237 | delete config.devtool;
238 | configureEsbuildMinifyPlugin(config);
239 | },
240 | webpack,
241 | );
242 |
243 | const { stats } = built;
244 | expect(stats.hasWarnings()).toBe(false);
245 | expect(stats.hasErrors()).toBe(false);
246 | expect(
247 | Object.keys(stats.compilation.assets).length,
248 | ).toBe(1);
249 |
250 | const file = built.fs.readFileSync('/dist/index.js', 'utf8');
251 | expect(file).not.toContain('//# sourceURL');
252 | });
253 |
254 | test('minify w/ devtool inline-source-map', async () => {
255 | const built = await build(
256 | fixtures.blank,
257 | (config) => {
258 | config.devtool = 'inline-source-map';
259 | configureEsbuildMinifyPlugin(config);
260 | },
261 | webpack,
262 | );
263 |
264 | const { stats } = built;
265 | expect(stats.hasWarnings()).toBe(false);
266 | expect(stats.hasErrors()).toBe(false);
267 | expect(
268 | Object.keys(stats.compilation.assets).length,
269 | ).toBe(1);
270 |
271 | const file = built.fs.readFileSync('/dist/index.js', 'utf8');
272 | expect(file).toContain('//# sourceMappingURL=data:application/');
273 | });
274 |
275 | test('minify w/ devtool source-map', async () => {
276 | const built = await build(
277 | fixtures.blank,
278 | (config) => {
279 | config.devtool = 'source-map';
280 | configureEsbuildMinifyPlugin(config);
281 | },
282 | webpack,
283 | );
284 |
285 | const { stats } = built;
286 | expect(stats.hasWarnings()).toBe(false);
287 | expect(stats.hasErrors()).toBe(false);
288 | expect(
289 | Object.keys(stats.compilation.assets),
290 | ).toStrictEqual([
291 | 'index.js',
292 | 'index.js.map',
293 | ]);
294 |
295 | const file = built.fs.readFileSync('/dist/index.js', 'utf8');
296 | expect(file).toContain('//# sourceMappingURL=index.js.map');
297 | });
298 |
299 | test('minify w/ source-map option and source-map plugin inline', async () => {
300 | const built = await build(
301 | fixtures.blank,
302 | (config) => {
303 | delete config.devtool;
304 | configureEsbuildMinifyPlugin(config);
305 |
306 | config.plugins!.push(
307 | new webpack.SourceMapDevToolPlugin({}) as Webpack['SourceMapDevToolPlugin'],
308 | );
309 | },
310 | webpack,
311 | );
312 |
313 | const { stats } = built;
314 | expect(stats.hasWarnings()).toBe(false);
315 | expect(stats.hasErrors()).toBe(false);
316 | expect(
317 | Object.keys(stats.compilation.assets).length,
318 | ).toBe(1);
319 |
320 | const file = built.fs.readFileSync('/dist/index.js', 'utf8');
321 | expect(file).toContain('//# sourceMappingURL=data:application/');
322 | });
323 |
324 | test('minify w/ source-map option and source-map plugin external', async () => {
325 | const built = await build(
326 | fixtures.blank,
327 | (config) => {
328 | delete config.devtool;
329 | configureEsbuildMinifyPlugin(config);
330 |
331 | config.plugins!.push(
332 | new webpack.SourceMapDevToolPlugin({
333 | filename: 'index.js.map',
334 | }) as Webpack['SourceMapDevToolPlugin'],
335 | );
336 | },
337 | webpack,
338 | );
339 |
340 | const { stats } = built;
341 | expect(stats.hasWarnings()).toBe(false);
342 | expect(stats.hasErrors()).toBe(false);
343 | expect(
344 | Object.keys(stats.compilation.assets),
345 | ).toStrictEqual([
346 | 'index.js',
347 | 'index.js.map',
348 | ]);
349 |
350 | const file = built.fs.readFileSync('/dist/index.js', 'utf8');
351 | expect(file).toContain('//# sourceMappingURL=index.js.map');
352 | });
353 | });
354 |
355 | test('minify w/ query strings', async () => {
356 | const built = await build(
357 | {
358 | '/src/index.js': 'import(/* webpackChunkName: "chunk" */"./chunk.js")',
359 | '/src/chunk.js': '',
360 | },
361 | (config) => {
362 | config.output!.filename = '[name].js?foo=bar';
363 | config.output!.chunkFilename = '[name].js?foo=bar';
364 |
365 | configureEsbuildMinifyPlugin(config);
366 | },
367 | webpack,
368 | );
369 |
370 | const { stats } = built;
371 | expect(stats.hasWarnings()).toBe(false);
372 | expect(stats.hasErrors()).toBe(false);
373 | expect(
374 | Object.keys(stats.compilation.assets).sort(),
375 | ).toStrictEqual([
376 | 'chunk.js?foo=bar',
377 | 'index.js?foo=bar',
378 | ]);
379 |
380 | // The actual file name does not include the query string
381 | const file = built.fs.readFileSync('/dist/index.js', 'utf8');
382 | expect(file).toMatch('?foo=bar');
383 | });
384 |
385 | describe('legalComments', ({ test }) => {
386 | test('minify w/ legalComments - default is inline', async () => {
387 | const builtDefault = await build(
388 | fixtures.legalComments,
389 | (config) => {
390 | configureEsbuildMinifyPlugin(config);
391 | },
392 | webpack,
393 | );
394 |
395 | const builtInline = await build(
396 | fixtures.legalComments,
397 | (config) => {
398 | configureEsbuildMinifyPlugin(config, {
399 | legalComments: 'inline',
400 | });
401 | },
402 | webpack,
403 | );
404 |
405 | const fileInline = builtInline.fs.readFileSync('/dist/index.js', 'utf8');
406 | const fileDefault = builtDefault.fs.readFileSync('/dist/index.js', 'utf8');
407 |
408 | expect(fileDefault).toMatch('//! legal comment');
409 | expect(fileDefault).toBe(fileInline);
410 | });
411 |
412 | test('minify w/ legalComments - eof', async () => {
413 | const built = await build(
414 | fixtures.legalComments,
415 | (config) => {
416 | configureEsbuildMinifyPlugin(config, {
417 | legalComments: 'eof',
418 | });
419 | },
420 | webpack,
421 | );
422 |
423 | expect(built.stats.hasWarnings()).toBe(false);
424 | expect(built.stats.hasErrors()).toBe(false);
425 |
426 | const file = built.fs.readFileSync('/dist/index.js').toString();
427 | expect(file.trim().endsWith('//! legal comment')).toBe(true);
428 | });
429 |
430 | test('minify w/ legalComments - none', async () => {
431 | const built = await build(
432 | fixtures.legalComments,
433 | (config) => {
434 | configureEsbuildMinifyPlugin(config, {
435 | legalComments: 'none',
436 | });
437 | },
438 | webpack,
439 | );
440 |
441 | expect(built.stats.hasWarnings()).toBe(false);
442 | expect(built.stats.hasErrors()).toBe(false);
443 |
444 | const file = built.fs.readFileSync('/dist/index.js', 'utf8');
445 | expect(file).not.toMatch('//! legal comment');
446 | });
447 |
448 | test('minify w/ legalComments - external', async () => {
449 | const built = await build(
450 | fixtures.legalComments,
451 | (config) => {
452 | configureEsbuildMinifyPlugin(config, {
453 | legalComments: 'external',
454 | });
455 | },
456 | webpack,
457 | );
458 |
459 | expect(built.stats.hasWarnings()).toBe(false);
460 | expect(built.stats.hasErrors()).toBe(false);
461 |
462 | expect(Object.keys(built.stats.compilation.assets)).toStrictEqual([
463 | 'index.js',
464 | 'index.js.LEGAL.txt',
465 | ]);
466 | const file = built.fs.readFileSync('/dist/index.js', 'utf8');
467 | expect(file).not.toMatch('//! legal comment');
468 |
469 | const extracted = built.fs.readFileSync('/dist/index.js.LEGAL.txt', 'utf8');
470 | expect(extracted).toMatch('//! legal comment');
471 | });
472 | });
473 | });
474 |
475 | describe('implementation', ({ test }) => {
476 | test('error', async () => {
477 | const runWithImplementation = async (implementation: EsbuildPluginOptions['implementation']) => {
478 | await build(
479 | fixtures.blank,
480 | (config) => {
481 | configureEsbuildMinifyPlugin(config, {
482 | implementation,
483 | });
484 | },
485 | webpack,
486 | );
487 | };
488 |
489 | await expect(
490 | // @ts-expect-error testing invalid type
491 | runWithImplementation({}),
492 | ).rejects.toThrow(
493 | '[EsbuildPlugin] implementation.transform must be an esbuild transform function. Received undefined',
494 | );
495 |
496 | await expect(
497 | // @ts-expect-error testing invalid type
498 | runWithImplementation({ transform: 123 }),
499 | ).rejects.toThrow(
500 | '[EsbuildPlugin] implementation.transform must be an esbuild transform function. Received number',
501 | );
502 | });
503 |
504 | test('customizable', async () => {
505 | const code = 'export function foo() { return "CUSTOM_ESBUILD_IMPLEMENTATION"; }';
506 | const built = await build(
507 | fixtures.blank,
508 | (config) => {
509 | configureEsbuildMinifyPlugin(config, {
510 | implementation: {
511 | transform: async () => ({
512 | code,
513 | map: '',
514 | warnings: [],
515 | mangleCache: {},
516 | legalComments: '',
517 | }),
518 | },
519 | });
520 | },
521 | webpack,
522 | );
523 |
524 | expect(built.stats.hasWarnings()).toBe(false);
525 | expect(built.stats.hasErrors()).toBe(false);
526 | expect(
527 | built.fs.readFileSync('/dist/index.js', 'utf8'),
528 | ).toBe(code);
529 | });
530 |
531 | test('customize with real esbuild', async () => {
532 | const built = await build(
533 | fixtures.minification,
534 | (config) => {
535 | configureEsbuildMinifyPlugin(config, {
536 | implementation: esbuild,
537 | });
538 | },
539 | webpack,
540 | );
541 |
542 | expect(built.stats.hasWarnings()).toBe(false);
543 | expect(built.stats.hasErrors()).toBe(false);
544 |
545 | const exportedFunction = built.require('/dist/');
546 | expect(exportedFunction('hello world')).toBe('hello world');
547 | assertMinified(exportedFunction.toString());
548 | });
549 | });
550 |
551 | describe('CSS', ({ test }) => {
552 | test('minify CSS asset', async () => {
553 | const built = await build(
554 | fixtures.css,
555 | (config) => {
556 | configureEsbuildMinifyPlugin(config, {
557 | css: true,
558 | });
559 | configureMiniCssExtractPlugin(config);
560 | },
561 | webpack,
562 | );
563 |
564 | expect(built.stats.hasWarnings()).toBe(false);
565 | expect(built.stats.hasErrors()).toBe(false);
566 |
567 | const file = built.fs.readFileSync('/dist/index.css').toString();
568 | expect(file.trim()).not.toMatch(/\s{2,}/);
569 | });
570 |
571 | test('exclude', async () => {
572 | const built = await build(
573 | fixtures.css,
574 | (config) => {
575 | configureEsbuildMinifyPlugin(config, {
576 | css: true,
577 | exclude: /index\.css$/,
578 | });
579 | configureMiniCssExtractPlugin(config);
580 | },
581 | webpack,
582 | );
583 |
584 | expect(built.stats.hasWarnings()).toBe(false);
585 | expect(built.stats.hasErrors()).toBe(false);
586 |
587 | const file = built.fs.readFileSync('/dist/index.css').toString();
588 | expect(file.trim()).toMatch(/\s{2,}/);
589 | });
590 |
591 | test('minify w/ source-map', async () => {
592 | const built = await build(
593 | fixtures.css,
594 | (config) => {
595 | config.devtool = 'source-map';
596 | configureEsbuildMinifyPlugin(config, {
597 | css: true,
598 | });
599 | configureMiniCssExtractPlugin(config);
600 | },
601 | webpack,
602 | );
603 |
604 | expect(built.stats.hasWarnings()).toBe(false);
605 | expect(built.stats.hasErrors()).toBe(false);
606 |
607 | const cssFile = built.fs.readFileSync('/dist/index.css').toString();
608 | const css = cssFile.trim().split('\n');
609 | expect(css[0]).not.toMatch(/\s{2,}/);
610 | expect(css[2]).toMatch(/sourceMappingURL/);
611 |
612 | const sourcemapFile = built.fs.readFileSync('/dist/index.css.map', 'utf8');
613 | expect(sourcemapFile).toMatch(/styles\.css/);
614 | });
615 | });
616 |
617 | test('supports Source without #sourceAndMap()', async () => {
618 | const createSource = (content: string) => ({
619 | source: () => content,
620 | size: () => Buffer.byteLength(content),
621 | }) as webpack5.sources.Source;
622 |
623 | const built = await build(fixtures.blank, (config) => {
624 | configureEsbuildMinifyPlugin(config);
625 |
626 | config.plugins!.push({
627 | apply: (compiler) => {
628 | compiler.hooks.compilation.tap('test', (compilation) => {
629 | compilation.hooks.processAssets.tap(
630 | { name: 'test' },
631 | () => {
632 | compilation.emitAsset(
633 | 'test.js',
634 | createSource('console.log( 1 + 1)'),
635 | );
636 | },
637 | );
638 | });
639 | },
640 | });
641 | }, webpack5);
642 |
643 | expect(built.stats.hasWarnings()).toBe(false);
644 | expect(built.stats.hasErrors()).toBe(false);
645 |
646 | expect(Object.keys(built.stats.compilation.assets)).toStrictEqual([
647 | 'index.js',
648 | 'test.js',
649 | ]);
650 | expect(
651 | built.fs.readFileSync('/dist/test.js', 'utf8'),
652 | ).toBe('console.log(2);\n');
653 | });
654 |
655 | describe('minify targets', ({ test }) => {
656 | test('no iife for node', async () => {
657 | const built = await build(
658 | fixtures.getHelpers,
659 | (config) => {
660 | configureEsbuildMinifyPlugin(config, {
661 | target: 'es2015',
662 | });
663 |
664 | config.target = webpackIs4 ? 'node' : ['node'];
665 | delete config.output?.libraryTarget;
666 | delete config.output?.libraryExport;
667 | },
668 | webpack,
669 | );
670 |
671 | expect(built.stats.hasWarnings()).toBe(false);
672 | expect(built.stats.hasErrors()).toBe(false);
673 |
674 | const code = built.fs.readFileSync('/dist/index.js', 'utf8').toString();
675 | expect(code.startsWith('var ')).toBe(true);
676 | });
677 |
678 | test('no iife for web with high target (no helpers are added)', async () => {
679 | const built = await build(
680 | fixtures.getHelpers,
681 | (config) => {
682 | configureEsbuildMinifyPlugin(config);
683 |
684 | config.target = webpackIs4 ? 'web' : ['web'];
685 | delete config.output?.libraryTarget;
686 | delete config.output?.libraryExport;
687 | },
688 | webpack,
689 | );
690 |
691 | expect(built.stats.hasWarnings()).toBe(false);
692 | expect(built.stats.hasErrors()).toBe(false);
693 |
694 | const code = built.fs.readFileSync('/dist/index.js', 'utf8').toString();
695 | expect(code.startsWith('(()=>{var ')).toBe(false);
696 | expect(countIife(code)).toBe(webpackIs4 ? 0 : 1);
697 | });
698 |
699 | test('iife for web & low target', async () => {
700 | const built = await build(
701 | fixtures.getHelpers,
702 | (config) => {
703 | configureEsbuildMinifyPlugin(config, {
704 | target: 'es2015',
705 | });
706 |
707 | config.target = webpackIs4 ? 'web' : ['web'];
708 | delete config.output?.libraryTarget;
709 | delete config.output?.libraryExport;
710 | },
711 | webpack,
712 | );
713 |
714 | expect(built.stats.hasWarnings()).toBe(false);
715 | expect(built.stats.hasErrors()).toBe(false);
716 |
717 | const code = built.fs.readFileSync('/dist/index.js', 'utf8').toString();
718 | expect(code.startsWith('(()=>{var ')).toBe(true);
719 | expect(code.endsWith('})();\n')).toBe(true);
720 | expect(countIife(code)).toBe(webpackIs4 ? 1 : 2);
721 | });
722 | });
723 |
724 | test('supports webpack-merge', async () => {
725 | const built = await build(
726 | fixtures.minification,
727 | (config) => {
728 | configureEsbuildMinifyPlugin(config);
729 | const clonedConfig = merge({}, config);
730 | config.optimization = clonedConfig.optimization;
731 | },
732 | webpack,
733 | );
734 |
735 | expect(built.stats.hasWarnings()).toBe(false);
736 | expect(built.stats.hasErrors()).toBe(false);
737 |
738 | const exportedFunction = built.require('/dist/');
739 | expect(exportedFunction('hello world')).toBe('hello world');
740 | assertMinified(exportedFunction.toString());
741 | });
742 |
743 | // https://github.com/privatenumber/esbuild-loader/issues/356
744 | test('can handle empty modules set', async () => {
745 | await expect(build(
746 | fixtures.blank,
747 | (config) => {
748 | config.entry = 'not-there.js';
749 | configureEsbuildMinifyPlugin(config);
750 | },
751 | webpack,
752 | )).resolves.toBeTruthy();
753 | });
754 |
755 | test('multiple plugins', async () => {
756 | const built = await build(
757 | fixtures.define,
758 | (config) => {
759 | configureEsbuildMinifyPlugin(config);
760 | config.plugins?.push(
761 | new EsbuildPlugin({
762 | define: {
763 | __TEST1__: '123',
764 | },
765 | }),
766 | new EsbuildPlugin({
767 | define: {
768 | __TEST2__: '321',
769 | },
770 | }),
771 | );
772 | },
773 | webpack,
774 | );
775 |
776 | expect(built.stats.hasWarnings()).toBe(false);
777 | expect(built.stats.hasErrors()).toBe(false);
778 |
779 | const exportedFunction = built.require('/dist/');
780 | expect(exportedFunction('hello world')).toStrictEqual([123, 321]);
781 | });
782 | });
783 | });
784 |
--------------------------------------------------------------------------------
/tests/specs/tsconfig.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { createRequire } from 'node:module';
3 | import { testSuite, expect } from 'manten';
4 | import { createFixture } from 'fs-fixture';
5 | import { execa } from 'execa';
6 | import { tsconfigJson } from '../utils.js';
7 |
8 | const require = createRequire(import.meta.url);
9 |
10 | const webpackCli = path.resolve('node_modules/webpack-cli/bin/cli.js');
11 | const esbuildLoader = path.resolve('dist/index.cjs');
12 |
13 | const detectStrictMode = '(function() { return !this; })()';
14 |
15 | export default testSuite(({ describe }) => {
16 | describe('tsconfig', ({ describe }) => {
17 | describe('loader', ({ test }) => {
18 | test('finds tsconfig.json and applies strict mode', async () => {
19 | await using fixture = await createFixture({
20 | src: {
21 | 'index.ts': `module.exports = [
22 | ${detectStrictMode},
23 | require("./not-strict.ts"),
24 | require("./different-config/strict.ts"),
25 | ];`,
26 | 'not-strict.ts': `module.exports = ${detectStrictMode}`,
27 | 'different-config': {
28 | 'strict.ts': `module.exports = ${detectStrictMode}`,
29 | 'tsconfig.json': tsconfigJson({
30 | compilerOptions: {
31 | strict: true,
32 | },
33 | }),
34 | },
35 | },
36 | 'webpack.config.js': `
37 | module.exports = {
38 | mode: 'production',
39 |
40 | optimization: {
41 | minimize: false,
42 | },
43 |
44 | resolveLoader: {
45 | alias: {
46 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)},
47 | },
48 | },
49 |
50 | module: {
51 | rules: [{
52 | test: /\\.ts$/,
53 | loader: 'esbuild-loader',
54 | }],
55 | },
56 |
57 | entry: './src/index.ts',
58 |
59 | output: {
60 | libraryTarget: 'commonjs2',
61 | },
62 | };
63 | `,
64 | 'tsconfig.json': tsconfigJson({
65 | compilerOptions: {
66 | strict: true,
67 | },
68 | include: [
69 | 'src/index.ts',
70 | ],
71 | }),
72 | });
73 |
74 | await execa(webpackCli, {
75 | cwd: fixture.path,
76 | });
77 |
78 | expect(
79 | require(path.join(fixture.path, 'dist/main.js')),
80 | ).toStrictEqual([true, false, true]);
81 | });
82 |
83 | test('handles resource with query', async () => {
84 | await using fixture = await createFixture({
85 | src: {
86 | 'index.ts': `module.exports = [${detectStrictMode}, require("./not-strict.ts?some-query")];`,
87 | 'not-strict.ts': `module.exports = ${detectStrictMode}`,
88 | },
89 | 'webpack.config.js': `
90 | module.exports = {
91 | mode: 'production',
92 |
93 | optimization: {
94 | minimize: false,
95 | },
96 |
97 | resolveLoader: {
98 | alias: {
99 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)},
100 | },
101 | },
102 |
103 | module: {
104 | rules: [{
105 | test: /\\.ts$/,
106 | loader: 'esbuild-loader',
107 | }],
108 | },
109 |
110 | entry: './src/index.ts',
111 |
112 | output: {
113 | libraryTarget: 'commonjs2',
114 | },
115 | };
116 | `,
117 | 'tsconfig.json': tsconfigJson({
118 | compilerOptions: {
119 | strict: true,
120 | },
121 | include: [
122 | 'src/index.ts',
123 | ],
124 | }),
125 | });
126 |
127 | await execa(webpackCli, {
128 | cwd: fixture.path,
129 | });
130 |
131 | expect(
132 | require(path.join(fixture.path, 'dist/main.js')),
133 | ).toStrictEqual([true, false]);
134 | });
135 |
136 | test('accepts custom tsconfig.json path', async () => {
137 | await using fixture = await createFixture({
138 | src: {
139 | 'index.ts': `module.exports = [${detectStrictMode}, require("./strict.ts")];`,
140 | 'strict.ts': `module.exports = ${detectStrictMode}`,
141 | },
142 | 'webpack.config.js': `
143 | module.exports = {
144 | mode: 'production',
145 |
146 | optimization: {
147 | minimize: false,
148 | },
149 |
150 | resolveLoader: {
151 | alias: {
152 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)},
153 | },
154 | },
155 |
156 | module: {
157 | rules: [{
158 | test: /\\.ts$/,
159 | loader: 'esbuild-loader',
160 | options: {
161 | tsconfig: './tsconfig.custom.json',
162 | }
163 | }],
164 | },
165 |
166 | entry: './src/index.ts',
167 |
168 | output: {
169 | libraryTarget: 'commonjs2',
170 | },
171 | };
172 | `,
173 | 'tsconfig.custom.json': tsconfigJson({
174 | compilerOptions: {
175 | strict: true,
176 | },
177 | include: [
178 | 'src/strict.ts',
179 | ],
180 | }),
181 | });
182 |
183 | const { stdout } = await execa(webpackCli, {
184 | cwd: fixture.path,
185 | });
186 |
187 | expect(stdout).toMatch('does not match its "include" patterns');
188 |
189 | expect(
190 | require(path.join(fixture.path, 'dist/main.js')),
191 | ).toStrictEqual([true, true]);
192 | });
193 |
194 | test('applies different tsconfig.json paths', async () => {
195 | await using fixture = await createFixture({
196 | src: {
197 | 'index.ts': 'export class C { foo = 100; }',
198 | 'index2.ts': 'export class C { foo = 100; }',
199 | },
200 | 'webpack.config.js': `
201 | module.exports = {
202 | mode: 'production',
203 |
204 | optimization: {
205 | minimize: false,
206 | },
207 |
208 | resolveLoader: {
209 | alias: {
210 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)},
211 | },
212 | },
213 |
214 | module: {
215 | rules: [
216 | {
217 | test: /index\\.ts$/,
218 | loader: 'esbuild-loader',
219 | options: {
220 | tsconfig: './tsconfig.custom1.json',
221 | }
222 | },
223 | {
224 | test: /index2\\.ts$/,
225 | loader: 'esbuild-loader',
226 | options: {
227 | tsconfig: './tsconfig.custom2.json',
228 | }
229 | }
230 | ],
231 | },
232 |
233 | entry: {
234 | index1: './src/index.ts',
235 | index2: './src/index2.ts',
236 | },
237 |
238 | output: {
239 | libraryTarget: 'commonjs2',
240 | },
241 | };
242 | `,
243 | 'tsconfig.custom1.json': tsconfigJson({
244 | compilerOptions: {
245 | useDefineForClassFields: false,
246 | },
247 | }),
248 | 'tsconfig.custom2.json': tsconfigJson({
249 | compilerOptions: {
250 | useDefineForClassFields: true,
251 | },
252 | }),
253 | });
254 |
255 | await execa(webpackCli, {
256 | cwd: fixture.path,
257 | });
258 |
259 | const code1 = await fixture.readFile('dist/index1.js', 'utf8');
260 | expect(code1).toMatch('this.foo = 100;');
261 |
262 | const code2 = await fixture.readFile('dist/index2.js', 'utf8');
263 | expect(code2).toMatch('__publicField(this, "foo", 100);');
264 | });
265 |
266 | test('fails on invalid tsconfig.json', async () => {
267 | await using fixture = await createFixture({
268 | 'tsconfig.json': tsconfigJson({
269 | extends: 'unresolvable-dep',
270 | }),
271 | src: {
272 | 'index.ts': `
273 | console.log('Hello, world!' as numer);
274 | `,
275 | },
276 | 'webpack.config.js': `
277 | module.exports = {
278 | mode: 'production',
279 |
280 | optimization: {
281 | minimize: false,
282 | },
283 |
284 | resolveLoader: {
285 | alias: {
286 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)},
287 | },
288 | },
289 |
290 | resolve: {
291 | extensions: ['.ts', '.js'],
292 | },
293 |
294 | module: {
295 | rules: [
296 | {
297 | test: /.[tj]sx?$/,
298 | loader: 'esbuild-loader',
299 | options: {
300 | target: 'es2015',
301 | }
302 | }
303 | ],
304 | },
305 |
306 | entry: {
307 | index: './src/index.ts',
308 | },
309 | };
310 | `,
311 | });
312 |
313 | const { stdout, exitCode } = await execa(webpackCli, {
314 | cwd: fixture.path,
315 | reject: false,
316 | });
317 |
318 | expect(stdout).toMatch('Error parsing tsconfig.json:\nFile \'unresolvable-dep\' not found.');
319 | expect(exitCode).toBe(1);
320 | });
321 |
322 | test('ignores invalid tsconfig.json in JS dependencies', async () => {
323 | await using fixture = await createFixture({
324 | 'node_modules/fake-lib': {
325 | 'package.json': JSON.stringify({
326 | name: 'fake-lib',
327 | }),
328 | 'tsconfig.json': tsconfigJson({
329 | extends: 'unresolvable-dep',
330 | }),
331 | 'index.js': 'export function testFn() { return "Hi!" }',
332 | },
333 | 'src/index.ts': `
334 | import { testFn } from "fake-lib";
335 | testFn();
336 | `,
337 | 'webpack.config.js': `
338 | module.exports = {
339 | mode: 'production',
340 |
341 | optimization: {
342 | minimize: false,
343 | },
344 |
345 | resolveLoader: {
346 | alias: {
347 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)},
348 | },
349 | },
350 |
351 | resolve: {
352 | extensions: ['.ts', '.js'],
353 | },
354 |
355 | module: {
356 | rules: [
357 | {
358 | test: /.[tj]sx?$/,
359 | loader: 'esbuild-loader',
360 | options: {
361 | target: 'es2015',
362 | }
363 | }
364 | ],
365 | },
366 |
367 | entry: {
368 | index: './src/index.ts',
369 | },
370 | };
371 | `,
372 | });
373 |
374 | const { stdout, exitCode } = await execa(webpackCli, {
375 | cwd: fixture.path,
376 | });
377 |
378 | expect(stdout).not.toMatch('Error parsing tsconfig.json');
379 | expect(exitCode).toBe(0);
380 | });
381 |
382 | test('warns on invalid tsconfig.json in TS dependencies', async () => {
383 | await using fixture = await createFixture({
384 | 'node_modules/fake-lib': {
385 | 'package.json': JSON.stringify({
386 | name: 'fake-lib',
387 | }),
388 | 'tsconfig.json': tsconfigJson({
389 | extends: 'unresolvable-dep',
390 | }),
391 | 'index.ts': 'export function testFn(): string { return "Hi!" }',
392 | },
393 | 'src/index.ts': `
394 | import { testFn } from "fake-lib";
395 | testFn();
396 | `,
397 | 'webpack.config.js': `
398 | module.exports = {
399 | mode: 'production',
400 |
401 | optimization: {
402 | minimize: false,
403 | },
404 |
405 | resolveLoader: {
406 | alias: {
407 | 'esbuild-loader': ${JSON.stringify(esbuildLoader)},
408 | },
409 | },
410 |
411 | resolve: {
412 | extensions: ['.ts', '.js'],
413 | },
414 |
415 | module: {
416 | rules: [
417 | {
418 | test: /.[tj]sx?$/,
419 | loader: 'esbuild-loader',
420 | options: {
421 | target: 'es2015',
422 | }
423 | }
424 | ],
425 | },
426 |
427 | entry: {
428 | index: './src/index.ts',
429 | },
430 | };
431 | `,
432 | });
433 |
434 | const { stdout, exitCode } = await execa(webpackCli, {
435 | cwd: fixture.path,
436 | });
437 |
438 | expect(stdout).toMatch('Error parsing tsconfig.json:\nFile \'unresolvable-dep\' not found.');
439 |
440 | // Warning so doesn't fail
441 | expect(exitCode).toBe(0);
442 | });
443 | });
444 |
445 | describe('plugin', ({ test }) => {
446 | /**
447 | * Since the plugin applies on distribution assets, it should not apply
448 | * any tsconfig settings.
449 | */
450 | test('should not detect tsconfig.json and apply strict mode', async () => {
451 | await using fixture = await createFixture({
452 | src: {
453 | 'index.js': 'console.log(1)',
454 | },
455 | 'webpack.config.js': `
456 | const { EsbuildPlugin } = require(${JSON.stringify(esbuildLoader)});
457 | module.exports = {
458 | mode: 'production',
459 | optimization: {
460 | minimizer: [
461 | new EsbuildPlugin(),
462 | ],
463 | },
464 | entry: './src/index.js',
465 | };
466 | `,
467 | 'tsconfig.json': tsconfigJson({
468 | compilerOptions: {
469 | strict: true,
470 | },
471 | }),
472 | });
473 |
474 | await execa(webpackCli, {
475 | cwd: fixture.path,
476 | });
477 |
478 | const code = await fixture.readFile('dist/main.js', 'utf8');
479 | expect(code).not.toMatch('use strict');
480 | });
481 | });
482 | });
483 | });
484 |
--------------------------------------------------------------------------------
/tests/specs/webpack5.ts:
--------------------------------------------------------------------------------
1 | import { testSuite, expect } from 'manten';
2 | import { build } from 'webpack-test-utils';
3 | import webpack5 from 'webpack5';
4 | import { configureEsbuildMinifyPlugin } from '../utils.js';
5 |
6 | const { RawSource } = webpack5.sources;
7 |
8 | export default testSuite(({ describe }) => {
9 | describe('Webpack 5', ({ test }) => {
10 | test('Stats', async () => {
11 | const built = await build({ '/src/index.js': '' }, (config) => {
12 | configureEsbuildMinifyPlugin(config);
13 | }, webpack5);
14 |
15 | expect(built.stats.hasWarnings()).toBe(false);
16 | expect(built.stats.hasErrors()).toBe(false);
17 | expect(built.stats.toString().includes('[minimized]')).toBe(true);
18 | });
19 |
20 | test('Minifies new assets', async () => {
21 | const built = await build({ '/src/index.js': '' }, (config) => {
22 | configureEsbuildMinifyPlugin(config);
23 |
24 | config.plugins!.push({
25 | apply: (compiler) => {
26 | compiler.hooks.compilation.tap('test', (compilation) => {
27 | compilation.hooks.processAssets.tap(
28 | { name: 'test' },
29 | () => {
30 | compilation.emitAsset(
31 | 'test.js',
32 | new RawSource('const value = 1;\n\nexport default value;'),
33 | );
34 | },
35 | );
36 | });
37 | },
38 | });
39 | }, webpack5);
40 |
41 | expect(built.stats.hasWarnings()).toBe(false);
42 | expect(built.stats.hasErrors()).toBe(false);
43 |
44 | const asset = built.stats.compilation.getAsset('test.js');
45 | expect(asset!.info.minimized).toBe(true);
46 |
47 | const file = built.fs.readFileSync('/dist/test.js', 'utf8');
48 | expect(file).toBe('const e=1;export default 1;\n');
49 | });
50 |
51 | test('Doesnt minify minimized assets', async () => {
52 | let sourceAndMapCalled = false;
53 | await build({ '/src/index.js': '' }, (config) => {
54 | configureEsbuildMinifyPlugin(config);
55 |
56 | config.plugins!.push({
57 | apply: (compiler) => {
58 | compiler.hooks.compilation.tap('test', (compilation) => {
59 | compilation.hooks.processAssets.tap(
60 | { name: 'test' },
61 | () => {
62 | const asset = new RawSource('');
63 |
64 | // @ts-expect-error overwriting to make sure it's not called
65 | asset.sourceAndMap = () => {
66 | sourceAndMapCalled = true;
67 | };
68 |
69 | compilation.emitAsset(
70 | 'test.js',
71 | asset,
72 | { minimized: true },
73 | );
74 | },
75 | );
76 | });
77 | },
78 | });
79 | }, webpack5);
80 |
81 | expect(sourceAndMapCalled).toBe(false);
82 | });
83 | });
84 | });
85 |
--------------------------------------------------------------------------------
/tests/utils.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import type webpack4 from 'webpack';
3 | import type webpack5 from 'webpack5';
4 | import MiniCssExtractPlugin from 'mini-css-extract-plugin';
5 | import type { TsConfigJson } from 'get-tsconfig';
6 | import { EsbuildPlugin, type EsbuildPluginOptions } from '#esbuild-loader';
7 |
8 | const esbuildLoaderPath = path.resolve('./dist/index.cjs');
9 |
10 | type Webpack4 = typeof webpack4;
11 |
12 | type Webpack5 = typeof webpack5;
13 |
14 | export type Webpack = Webpack4 & Webpack5;
15 |
16 | export type WebpackConfiguration = webpack4.Configuration | webpack5.Configuration;
17 |
18 | type RuleSetUseItem = webpack4.RuleSetUseItem & webpack5.RuleSetUseItem;
19 |
20 | type RuleSetRule = webpack4.RuleSetRule & webpack5.RuleSetRule;
21 |
22 | export const isWebpack4 = (
23 | webpack: Webpack4 | Webpack5,
24 | ): webpack is Webpack4 => Boolean(webpack.version?.startsWith('4.'));
25 |
26 | export const configureEsbuildLoader = (
27 | config: WebpackConfiguration,
28 | rulesConfig?: RuleSetRule,
29 | ) => {
30 | config.resolveLoader!.alias = {
31 | 'esbuild-loader': esbuildLoaderPath,
32 | };
33 |
34 | config.module!.rules!.push({
35 | test: /\.js$/,
36 | loader: 'esbuild-loader',
37 | ...rulesConfig,
38 | options: {
39 | tsconfigRaw: undefined,
40 | ...(
41 | typeof rulesConfig?.options === 'object'
42 | ? rulesConfig.options
43 | : {}
44 | ),
45 | },
46 | });
47 | };
48 |
49 | export const configureEsbuildMinifyPlugin = (
50 | config: WebpackConfiguration,
51 | options?: EsbuildPluginOptions,
52 | ) => {
53 | config.optimization = {
54 | minimize: true,
55 | minimizer: [
56 | new EsbuildPlugin({
57 | tsconfigRaw: undefined,
58 | ...options,
59 | }),
60 | ],
61 | };
62 | };
63 |
64 | export const configureCssLoader = (
65 | config: WebpackConfiguration,
66 | ) => {
67 | const cssRule = {
68 | test: /\.css$/,
69 | use: [
70 | 'css-loader',
71 | ] as RuleSetUseItem[],
72 | };
73 | config.module!.rules!.push(cssRule);
74 | return cssRule;
75 | };
76 |
77 | export const configureMiniCssExtractPlugin = (
78 | config: WebpackConfiguration,
79 | ) => {
80 | const cssRule = configureCssLoader(config);
81 | cssRule.use.unshift(MiniCssExtractPlugin.loader);
82 |
83 | config.plugins!.push(
84 | // @ts-expect-error Forcing it to Webpack 5
85 | new MiniCssExtractPlugin(),
86 | );
87 | };
88 |
89 | export const tsconfigJson = (
90 | tsconfigObject: TsConfigJson,
91 | ) => JSON.stringify(tsconfigObject);
92 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2022",
4 | "lib": ["ESNext"],
5 | "moduleDetection": "force",
6 |
7 | "module": "preserve",
8 | "resolveJsonModule": true,
9 | "allowJs": true,
10 | "strict": true,
11 | // "noUncheckedIndexedAccess": true,
12 | "noImplicitOverride": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "verbatimModuleSyntax": true,
16 | "skipLibCheck": true,
17 | }
18 | }
--------------------------------------------------------------------------------