├── .github ├── ISSUE_TEMPLATE │ ├── 1-bug-report.md │ ├── 2-feature-request.md │ └── CONTRIBUTING.md └── workflows │ ├── e2e-test.yaml │ ├── publish.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .node-version ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── Q&A.md ├── README.md ├── __tests__ ├── compress.spec.ts ├── fixtures │ ├── dynamic │ │ ├── code.ts │ │ ├── index.html │ │ ├── main.ts │ │ └── style.css │ ├── exclude-assets │ │ ├── cat.gif │ │ ├── index.html │ │ └── main.js │ ├── normal │ │ ├── code.ts │ │ ├── index.html │ │ ├── main.ts │ │ └── style.css │ ├── optimization │ │ ├── font.css │ │ ├── index.html │ │ ├── main.js │ │ └── public │ │ │ └── XRXI3I6Li01BKofiOc5wtlZ2di8HDDshdTk3j77e.woff2 │ ├── public-assets-nest │ │ ├── index.html │ │ ├── main.js │ │ └── public │ │ │ ├── js │ │ │ ├── nest1 │ │ │ │ └── index.js │ │ │ └── nest2 │ │ │ │ └── index.js │ │ │ ├── normal.js │ │ │ └── theme │ │ │ ├── dark │ │ │ └── dark.css │ │ │ └── light │ │ │ └── light.css │ └── public-assets │ │ ├── index.html │ │ ├── main.js │ │ └── public │ │ ├── index.js │ │ └── openapi.yaml ├── plugin.spec.ts ├── shared │ └── kit.mts └── tarball.spec.ts ├── codecov.yml ├── dprint.json ├── e2e ├── e2e.ts ├── fixture │ ├── dynamic.ts │ ├── main.ts │ └── theme.css ├── index.html ├── package.json ├── vite2 │ ├── e2e.spec.ts │ ├── interface.ts │ └── package.json ├── vite3 │ ├── e2e.spec.ts │ ├── interface.ts │ └── package.json ├── vite4 │ ├── e2e.spec.ts │ ├── interface.ts │ └── package.json ├── vite5 │ ├── e2e.spec.ts │ ├── interface.ts │ └── package.json └── vite6 │ ├── e2e.spec.ts │ ├── interface.ts │ └── package.json ├── eslint.config.js ├── example ├── README.md ├── client │ └── src │ │ ├── dynamic.css │ │ ├── dynamic.js │ │ ├── main.js │ │ └── seq.js ├── index.html ├── package.json ├── public │ ├── css │ │ └── x.css │ └── js │ │ └── hello.js ├── server │ └── app.js └── vite.config.js ├── jiek.config.ts ├── package.json ├── patches └── vite@2.9.18.patch ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src ├── compress.ts ├── index.ts ├── interface.ts ├── shared.ts └── task.ts ├── tsconfig.json └── vitest.config.mts /.github/ISSUE_TEMPLATE/1-bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Bug report 🐞' 3 | about: "If something isn't working as expected 🤔." 4 | --- 5 | 6 | # Bug report 🐞 7 | 8 | ## Version & Environment 9 | 10 | ## Expection 11 | 12 | ## Actual results (or Errors) 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Feature request 🚀' 3 | about: 'I have a suggestion!' 4 | --- 5 | 6 | # Feature request 🚀 7 | 8 | ### Expected: 9 | 10 | - No breaking changes 11 | 12 | ### Examples: 13 | 14 | ```js 15 | ``` 16 | 17 | ### Programme: 18 | 19 | ### Others: 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Vite-Compression-plugin GUide 2 | 3 | ### Ready to start 4 | 5 | We welcome everyone to join the construction of the project. As a pre requirement. You need know 6 | Git, if you don't know it. There is a basic operation of git refer to [GitHub's help documentation](https://help.github.com/en/github/using-git). 7 | 8 | 1. [Fork this repository](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) to your own account and then clone it. 9 | 2. Create a new branch for your changes: `git checkout -b {BRANCH_NAME}`. 10 | 3. Install a package management tools [Yarn](https://classic.yarnpkg.com/en/docs/install#mac-stable) 11 | 4. Install project dependices. 12 | 5. Change the code you want. 13 | 14 | At any time, you think it's ok, you can start the following steps to submit your amazing works: 15 | 16 | 1. Run `yarn test` check the result. 17 | 18 | ### Q & A 19 | 20 | > What should i consider when testing. 21 | 22 | - Make sure branches are covered as much as possible. 23 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test.yaml: -------------------------------------------------------------------------------- 1 | name: E2e test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | run-e2e-test: 7 | strategy: 8 | matrix: 9 | version: [16, 18] 10 | os: [ubuntu-latest] 11 | runs-on: ${{ matrix.os }} 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | node-version: ${{ matrix.version }} 16 | - name: Instanll pnpm 17 | run: make install 18 | 19 | - name: install playwright 20 | run: pnpm playwright install 21 | 22 | - name: test 2.x to 4.x 23 | run: pnpm exec vitest e2e/vite[2-4]/*.spec.ts --coverage.enabled=false 24 | run-stable-e2e-test: 25 | strategy: 26 | matrix: 27 | version: [18, 20] 28 | os: [ubuntu-latest] 29 | runs-on: ${{ matrix.os }} 30 | steps: 31 | - uses: actions/checkout@v4 32 | with: 33 | node-version: ${{ matrix.version }} 34 | - name: Instanll pnpm 35 | run: make install 36 | 37 | - name: install playwright 38 | run: pnpm playwright install 39 | 40 | - name: test 5.x 41 | run: pnpm exec vitest e2e/vite[5-6]/*.spec.ts --coverage.enabled=false 42 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: publish 2 | on: 3 | push: 4 | tags: ["v*"] 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: read 11 | id-token: write 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 21 17 | registry-url: "https://registry.npmjs.org" 18 | - name: Pack and Publish 19 | run: | 20 | make build-pub 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | NPM_CONFIG_PROVENANCE: true 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: releaser 2 | 3 | on: 4 | push: 5 | tags: ["v*"] 6 | 7 | jobs: 8 | releaser: 9 | permissions: 10 | contents: write 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Extra Changelog 15 | run: | 16 | CHANGELOG=$(awk -v ver=$(awk -F'"' '/"version": ".+"/{ print $4; exit; }' package.json) '/^## / { if (p) { exit }; if ($2 == ver) { p=1; next} } p' CHANGELOG.md) 17 | echo "CHANGELOG<> $GITHUB_ENV 18 | echo "$CHANGELOG" >> $GITHUB_ENV 19 | echo "EOF" >> $GITHUB_ENV 20 | - name: Github Releaser 21 | uses: actions/create-release@v1 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | with: 25 | tag_name: ${{ github.ref }} 26 | body: ${{ env.CHANGELOG }} 27 | draft: false 28 | prerelease: false 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | node-version: 22.3 13 | - name: Build Library 14 | run: make bootstrap 15 | - name: Run Test 16 | run: make test 17 | 18 | - name: Upload coverage reports to Codecov 19 | uses: codecov/codecov-action@v3 20 | env: 21 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist 3 | temp 4 | types 5 | .tmpl 6 | .tmp 7 | tmp 8 | .dist 9 | coverage 10 | 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | .yarn/cache 15 | .yarn/install-state.gz 16 | 17 | .DS_Store 18 | 19 | tsconfig.vitest-temp.json 20 | 21 | .tmpl_suite -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | v22.3.0 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.useFlatConfig": true, 3 | "editor.defaultFormatter": "dprint.dprint", 4 | "dprint.path": "./node_modules/.bin/dprint", 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll": "explicit" 7 | }, 8 | "editor.formatOnSave": true 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.4.0 2 | 3 | Added build log output for compression result. 4 | 5 | This enhancement provides visibility into compression results during the build process, complementing Vite's default reporter by including assets from the public directory in the output statistics. 6 | 7 | ## 1.3.3 8 | 9 | # Patches 10 | 11 | - Fix error deps 12 | 13 | ## 1.3.2 14 | 15 | Support Vite6 16 | 17 | ## 1.3.1 18 | 19 | Using `jiek` 20 | 21 | ## 1.3.0 22 | 23 | # Improves 24 | 25 | - `tarball` support generate gz archive. 26 | 27 | # Patches 28 | 29 | - Fix vite@2 can't work. 30 | 31 | ## 1.2.0 32 | 33 | - Support work with plugins that specify hook order. 34 | 35 | ## 1.1.4 36 | 37 | - Migrate to `tar-mini` 38 | 39 | ## 1.1.3 40 | 41 | # Improves 42 | 43 | - Option `include` add more defaults. 44 | 45 | # Patches 46 | 47 | - Fix overload error. 48 | 49 | ### Credits 50 | 51 | @mengdaoshizhongxinyang @silverwind 52 | 53 | ## 1.1.2 54 | 55 | # Improve 56 | 57 | - Remove `tar-stream`. 58 | 59 | ## 1.1.1 60 | 61 | # Patches 62 | 63 | - Tarball should handle right static sources. 64 | 65 | ## 1.1.0 66 | 67 | # Improve 68 | 69 | - Option `include` add default value. 70 | 71 | ### Credits 72 | 73 | @Ibadichan 74 | 75 | ## 1.0.0 76 | 77 | # Background 78 | 79 | This is a stable version. 80 | 81 | ### Major 82 | 83 | - Rename plugin `cp` as `tarball` and remove unnecessary options. 84 | 85 | ### Improves 86 | 87 | - Optimize compression task. 88 | 89 | ## 0.12.0 90 | 91 | # Background 92 | 93 | ### Improves 94 | 95 | - expose new plugin `cp`.(a tarball helper) 96 | 97 | ## 0.11.0 98 | 99 | # Background 100 | 101 | - Details see #41 102 | 103 | ## 0.10.6 104 | 105 | # Background 106 | 107 | ### Improves 108 | 109 | - Perf types. 110 | - Reduce unnecessary installation packages. 111 | 112 | ## 0.10.5 113 | 114 | # Background 115 | 116 | ### Improves 117 | 118 | - Reduce bundle size. 119 | 120 | ### Patches 121 | 122 | - Fix can't work at monorepo. 123 | 124 | ## 0.10.4 125 | 126 | # Background 127 | 128 | - Make options happy 129 | 130 | ## 0.10.3 131 | 132 | # Background 133 | 134 | ### Patches 135 | 136 | - Fix output option duplicate. #39 137 | 138 | ## 0.10.2 139 | 140 | # Background 141 | 142 | ### Patches 143 | 144 | - Fix option `filename` called result same as bundle filename can't work. #31 145 | 146 | ## 0.10.1 147 | 148 | # Background 149 | 150 | ### Patches 151 | 152 | - Fix chunk with side effect can't work with `threshold` #33 153 | 154 | ## 0.10.0 155 | 156 | # Background 157 | 158 | ### Improve & Features 159 | 160 | - Add `skipIfLargerOrEqual` option. #32 161 | 162 | ### Credits 163 | 164 | @vHeemstra @nonzzz 165 | 166 | ## 0.9.3 167 | 168 | # Background 169 | 170 | ### Improve 171 | 172 | - Static Directory support size check. 173 | 174 | ## 0.9.2 175 | 176 | # Background 177 | 178 | ### Patches 179 | 180 | - Fix `filename` same as bundle source name can't work. #30 181 | 182 | ### Credits 183 | 184 | @jglee96 @nonzzz 185 | 186 | ## 0.9.1 187 | 188 | # Background 189 | 190 | ### Improve 191 | 192 | - Reduce unnecessary io (Currently, We don't handle viteMetaData.Becasue vite has already process them) 193 | - Add queue to optimize task processing. 194 | 195 | ### Patches 196 | 197 | - Fix that the file with side effect can't be filterd. 198 | - Static assets can't handle correctly. 199 | 200 | ## 0.9.0 201 | 202 | # Background 203 | 204 | - Support multiple process. #27 205 | 206 | ## 0.8.4 207 | 208 | # Background 209 | 210 | ### Patches 211 | 212 | - Fix filter can't work with dynamic source. #25 213 | 214 | ## 0.8.3 215 | 216 | # Background 217 | 218 | - Reduce bundle size. 219 | - Perf ReadAll func. 220 | 221 | ## 0.8.2 222 | 223 | # Background 224 | 225 | ### Patches 226 | 227 | - Fix nesting public assets can't work normal. #23 228 | 229 | ## 0.8.1 230 | 231 | # Background 232 | 233 | ### Patches 234 | 235 | - Fix public assets can't work with filter 236 | 237 | ## 0.8.0 238 | 239 | # Background 240 | 241 | ### Improves & Features 242 | 243 | - Support compress public resource. #22 244 | 245 | ## 0.7.0 246 | 247 | # Background 248 | 249 | ### Improves & Features 250 | 251 | - Remove hook order #20 252 | - Remove peerDependencies 253 | 254 | ## 0.6.3 255 | 256 | # Background 257 | 258 | ### Patches 259 | 260 | - Fix type error (#16) 261 | 262 | ### Credits 263 | 264 | @ModyQyW 265 | 266 | ## 0.6.2 267 | 268 | # Background 269 | 270 | - Fix bundle error 271 | 272 | ## 0.6.1 273 | 274 | # Background 275 | 276 | ### Improves & Features 277 | 278 | - Perf function type. (Details see #12) 279 | - Add `named exports`.(Some user prefer like their project is full esm. #14) 280 | 281 | ### Credits 282 | 283 | @MZ-Dlovely @ModyQyW 284 | 285 | ## 0.6.0 286 | 287 | # Background 288 | 289 | ### Improves & Features 290 | 291 | - Algorithm will using `gzip` If user pass a error union type. 292 | - Update document usage. 293 | 294 | ## 0.5.0 295 | 296 | # Background 297 | 298 | ### MINOR 299 | 300 | - Migrate vite version. 301 | - Peerdependencies support `vite3` and `vite`. 302 | 303 | ## 0.4.3 304 | 305 | # Background 306 | 307 | ### Patches 308 | 309 | - Fix dynamicImports loose right assets info.(Vite's intenral logic cause it. So we define the order for us plugin). 310 | 311 | ## 0.4.2 312 | 313 | # Background 314 | 315 | ### Minor 316 | 317 | - Perf chunk collect logic. In collect setp. Don't clone a new buffer data. 318 | - Change release file. using `.mjs`,`.js`. 319 | 320 | ### Others 321 | 322 | - Update project dependencies. 323 | 324 | ## 0.4.1 325 | 326 | # Background 327 | 328 | ### Patches 329 | 330 | - Fix dynamicImports can't generator right compressed file. 331 | 332 | ## 0.4.0 333 | 334 | # Background 335 | 336 | ### Improves & Features 337 | 338 | - Add `filename` prop.(Control the generator source name) 339 | - Enhanced type inference. 340 | - Performance optimization. 341 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 kanno 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | JK = pnpm exec jiek -f vite-plugin-compression2 2 | 3 | install: 4 | @echo "Setup pnpm package manager..." 5 | npm install -g corepack@latest --force 6 | @corepack enable 7 | pnpm install 8 | 9 | build: 10 | @echo "Building..." 11 | -rm -rf dist 12 | $(JK) build 13 | mv 'dist/index.min.js' 'dist/index.js' 14 | mv 'dist/index.min.mjs' 'dist/index.mjs' 15 | 16 | bootstrap: install build 17 | 18 | build-pub: install build 19 | @echo "Building for publish..." 20 | $(JK) pub -no-b 21 | 22 | test: 23 | @echo "Running tests..." 24 | @pnpm exec vitest --dir __tests__ --typecheck.enabled 25 | 26 | end-to-end-test: 27 | @echo "Running end-to-end tests..." 28 | @pnpm exec vitest e2e/**/*.spec.ts --coverage.enabled=false 29 | 30 | format: 31 | @echo "Format code" 32 | @pnpm exec dprint fmt -------------------------------------------------------------------------------- /Q&A.md: -------------------------------------------------------------------------------- 1 | # Q & A 2 | 3 | > What is this plugin do? 4 | 5 | - It's a simple zlib binding for vite, No a code compressor or a mangle. It help you compress your bundle assets in your local machine to save your precious server memory. 6 | 7 | > How do i know if i need this plugin? 8 | 9 | - Normally, You won't need it for most scenes. Follow the previous answer we know we only using it to compress us bundle asset in client, 10 | So if some other clould server provider provide the smae server, you don't need it. 11 | 12 | > How can i use it? 13 | 14 | - There are two step. 1, install this plugin and add it into your vite config then build your application, upload your bundle assets to your server. 15 | 2, Makesure you have already using `tomcat` or `nginx` or others proxy server and find the relevant configuration tutorial. Like nignix, you can refer 16 | [document](https://nginx.org/en/docs/http/ngx_http_gzip_module.html) 17 | 18 | > Why `vite-plugin-compression2` not `vite-plugin-compression`? 19 | 20 | - To be honest, It won't maintain anymore, So that i made a new one. 21 | 22 | > How can i define a custom compression algorithm? 23 | 24 | ```ts 25 | import { defineCompressionOption } from 'vite-plugin-compression2' 26 | import { ZlibOption } from 'zlib' 27 | 28 | const opt = defineCompressionOption({ 29 | // ... 30 | }) 31 | ``` 32 | 33 | > How can i generate multiple compressed assets with difference compression algorithm? 34 | 35 | ```ts 36 | import { defineComponent } from 'vite' 37 | import { compression } from 'vite-plugin-compression2' 38 | 39 | export default defineComponent({ 40 | plugins: [ 41 | // ...your plugin 42 | compression(), 43 | compression({ algorithm: 'brotliCompress' }) 44 | ] 45 | }) 46 | ``` 47 | 48 | > Can `tarball` be used only? 49 | 50 | - Yes. 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | [![codecov](https://codecov.io/gh/nonzzz/vite-plugin-compression/branch/master/graph/badge.svg?token=NG4475OP6B)](https://codecov.io/gh/nonzzz/vite-compression-plugin) 6 | 7 | ## Install 8 | 9 | ```bash 10 | $ yarn add vite-plugin-compression2 -D 11 | 12 | # or 13 | 14 | $ npm install vite-plugin-compression2 -D 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```js 20 | import { defineConfig } from 'vite' 21 | 22 | import { compression } from 'vite-plugin-compression2' 23 | 24 | export default defineConfig({ 25 | plugins: [ 26 | // ...your plugin 27 | compression() 28 | // If you want to create a tarball archive you can import tarball plugin from this package and use 29 | // after compression. 30 | ] 31 | }) 32 | ``` 33 | 34 | ## Options 35 | 36 | | params | type | default | description | 37 | | ---------------------- | --------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | 38 | | `include` | `string \| RegExp \| Array` | `/\.(html\|xml\|css\|json\|js\|mjs\|svg\|yaml\|yml\|toml)$/` | Include all assets matching any of these conditions. | 39 | | `exclude` | `string \| RegExp \| Array` | `-` | Exclude all assets matching any of these conditions. | 40 | | `threshold` | `number` | `0` | Only assets bigger than this size are processed (in bytes) | 41 | | `algorithm` | `string\| function` | `gzip` | The compression algorithm | 42 | | `compressionOptions` | `Record` | `{}` | Compression options for `algorithm`(details see `zlib module`) | 43 | | `deleteOriginalAssets` | `boolean` | `false` | Whether to delete the original assets or not | 44 | | `skipIfLargerOrEqual` | `boolean` | `true` | Whether to skip the compression if the result is larger than or equal to the original file | 45 | | `filename` | `string` | `[path][base].gz` | The target asset filename | 46 | 47 | ## Q & A 48 | 49 | [FAQ](./Q&A.md) 50 | 51 | ### Others 52 | 53 | - If you want to analysis your bundle assets. Maybe you can try [vite-bundle-analyzer](https://github.com/nonzzz/vite-bundle-analyzer) 54 | 55 | - `tarball` option `dest` means to generate a tarball somewhere 56 | 57 | - `tarball` is based on the `ustar`. It should be compatible with all popular tar distributions out there (gnutar, bsdtar etc) 58 | 59 | ### Sponsors 60 | 61 |

62 | 63 | 64 | 65 |

66 | 67 | ### LICENSE 68 | 69 | [MIT](./LICENSE) 70 | 71 | ## Acknowledgements 72 | 73 | [NWYLZW](https://github.com/NWYLZW) 74 | 75 | ### Author 76 | 77 | [Kanno](https://github.com/nonzzz) 78 | -------------------------------------------------------------------------------- /__tests__/compress.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import type { InputType } from 'zlib' 3 | import { compress, ensureAlgorithm } from '../src/compress' 4 | import type { Algorithm } from '../src/interface' 5 | 6 | const mockCompress = async (userAlgorithm: Algorithm, buf: InputType) => { 7 | const { algorithm } = ensureAlgorithm(userAlgorithm) 8 | return compress(buf, algorithm, {}) 9 | } 10 | 11 | test('compress with error', async () => { 12 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any 13 | await expect(mockCompress('gzip', 123 as any)).rejects.toThrowError( 14 | 'The "chunk" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received type number (123)' 15 | ) 16 | }) 17 | -------------------------------------------------------------------------------- /__tests__/fixtures/dynamic/code.ts: -------------------------------------------------------------------------------- 1 | const arr = [1, 2, 3, 4, 5, 6] 2 | 3 | export const result = arr.reduce((acc, cur) => (acc += cur), 0) 4 | -------------------------------------------------------------------------------- /__tests__/fixtures/dynamic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /__tests__/fixtures/dynamic/main.ts: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | 3 | import('./code').then(({ result }) => { 4 | console.log(result) 5 | }).catch(console.error) 6 | -------------------------------------------------------------------------------- /__tests__/fixtures/dynamic/style.css: -------------------------------------------------------------------------------- 1 | .pr { 2 | padding-right: 30px; 3 | } 4 | 5 | .pl { 6 | padding-left: 30px; 7 | } 8 | 9 | .mt { 10 | margin-top: 30px; 11 | } 12 | -------------------------------------------------------------------------------- /__tests__/fixtures/exclude-assets/cat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonzzz/vite-plugin-compression/13d0c2df0faa5fd6228fdd0fc8bfafa6efeb9d59/__tests__/fixtures/exclude-assets/cat.gif -------------------------------------------------------------------------------- /__tests__/fixtures/exclude-assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /__tests__/fixtures/exclude-assets/main.js: -------------------------------------------------------------------------------- 1 | import cat from './cat.gif' 2 | 3 | const app = document.querySelector('#app') 4 | 5 | const imgEl = document.createElement('img') 6 | 7 | imgEl.src = cat 8 | app.appendChild(imgEl) 9 | 10 | console.log('hello world') 11 | -------------------------------------------------------------------------------- /__tests__/fixtures/normal/code.ts: -------------------------------------------------------------------------------- 1 | const arr = [1, 2, 3, 4, 5, 6] 2 | 3 | export const result = arr.reduce((acc, cur) => (acc += cur), 0) 4 | -------------------------------------------------------------------------------- /__tests__/fixtures/normal/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /__tests__/fixtures/normal/main.ts: -------------------------------------------------------------------------------- 1 | import { result } from './code' 2 | import './style.css' 3 | 4 | console.log(result) 5 | -------------------------------------------------------------------------------- /__tests__/fixtures/normal/style.css: -------------------------------------------------------------------------------- 1 | .pr { 2 | padding-right: 30px; 3 | } 4 | 5 | .pl { 6 | padding-left: 30px; 7 | } 8 | 9 | .mt { 10 | margin-top: 30px; 11 | } 12 | -------------------------------------------------------------------------------- /__tests__/fixtures/optimization/font.css: -------------------------------------------------------------------------------- 1 | @font-face{ 2 | font-family: 'Nunito'; 3 | font-style: normal; 4 | font-weight: 200; 5 | font-display: swap; 6 | src: url(/XRXI3I6Li01BKofiOc5wtlZ2di8HDDshdTk3j77e.woff2) format('woff2'); 7 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 8 | } 9 | 10 | body{ 11 | font-family: 'Nunito', sans-serif; 12 | } -------------------------------------------------------------------------------- /__tests__/fixtures/optimization/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 |
123
10 | 11 | 12 | -------------------------------------------------------------------------------- /__tests__/fixtures/optimization/main.js: -------------------------------------------------------------------------------- 1 | import './font.css' 2 | 3 | console.log('optimization') 4 | -------------------------------------------------------------------------------- /__tests__/fixtures/optimization/public/XRXI3I6Li01BKofiOc5wtlZ2di8HDDshdTk3j77e.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonzzz/vite-plugin-compression/13d0c2df0faa5fd6228fdd0fc8bfafa6efeb9d59/__tests__/fixtures/optimization/public/XRXI3I6Li01BKofiOc5wtlZ2di8HDDshdTk3j77e.woff2 -------------------------------------------------------------------------------- /__tests__/fixtures/public-assets-nest/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /__tests__/fixtures/public-assets-nest/main.js: -------------------------------------------------------------------------------- 1 | console.log('nesting assets') 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/public-assets-nest/public/js/nest1/index.js: -------------------------------------------------------------------------------- 1 | console.log('nest 1') 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/public-assets-nest/public/js/nest2/index.js: -------------------------------------------------------------------------------- 1 | console.log('nest 2') 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/public-assets-nest/public/normal.js: -------------------------------------------------------------------------------- 1 | console.log('first') 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/public-assets-nest/public/theme/dark/dark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --dark-color: 'blue'; 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/fixtures/public-assets-nest/public/theme/light/light.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-color: red; 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/fixtures/public-assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /__tests__/fixtures/public-assets/main.js: -------------------------------------------------------------------------------- 1 | console.log('main process') 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/public-assets/public/index.js: -------------------------------------------------------------------------------- 1 | console.log('test public resource') 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/public-assets/public/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | license: 6 | name: MIT 7 | servers: 8 | - url: http://petstore.swagger.io/v1 9 | paths: 10 | /pets: 11 | get: 12 | summary: List all pets 13 | operationId: listPets 14 | tags: 15 | - pets 16 | parameters: 17 | - name: limit 18 | in: query 19 | description: How many items to return at one time (max 100) 20 | required: false 21 | schema: 22 | type: integer 23 | maximum: 100 24 | format: int32 25 | responses: 26 | "200": 27 | description: A paged array of pets 28 | headers: 29 | x-next: 30 | description: A link to the next page of responses 31 | schema: 32 | type: string 33 | content: 34 | application/json: 35 | schema: 36 | $ref: "#/components/schemas/Pets" 37 | default: 38 | description: unexpected error 39 | content: 40 | application/json: 41 | schema: 42 | $ref: "#/components/schemas/Error" 43 | post: 44 | summary: Create a pet 45 | operationId: createPets 46 | tags: 47 | - pets 48 | requestBody: 49 | content: 50 | application/json: 51 | schema: 52 | $ref: "#/components/schemas/Pet" 53 | required: true 54 | responses: 55 | "201": 56 | description: Null response 57 | default: 58 | description: unexpected error 59 | content: 60 | application/json: 61 | schema: 62 | $ref: "#/components/schemas/Error" 63 | /pets/{petId}: 64 | get: 65 | summary: Info for a specific pet 66 | operationId: showPetById 67 | tags: 68 | - pets 69 | parameters: 70 | - name: petId 71 | in: path 72 | required: true 73 | description: The id of the pet to retrieve 74 | schema: 75 | type: string 76 | responses: 77 | "200": 78 | description: Expected response to a valid request 79 | content: 80 | application/json: 81 | schema: 82 | $ref: "#/components/schemas/Pet" 83 | default: 84 | description: unexpected error 85 | content: 86 | application/json: 87 | schema: 88 | $ref: "#/components/schemas/Error" 89 | components: 90 | schemas: 91 | Pet: 92 | type: object 93 | required: 94 | - id 95 | - name 96 | properties: 97 | id: 98 | type: integer 99 | format: int64 100 | name: 101 | type: string 102 | tag: 103 | type: string 104 | Pets: 105 | type: array 106 | maxItems: 100 107 | items: 108 | $ref: "#/components/schemas/Pet" 109 | Error: 110 | type: object 111 | required: 112 | - code 113 | - message 114 | properties: 115 | code: 116 | type: integer 117 | format: int32 118 | message: 119 | type: string 120 | -------------------------------------------------------------------------------- /__tests__/plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import util from 'util' 2 | import zlib from 'zlib' 3 | import type { ZlibOptions } from 'zlib' 4 | import path from 'path' 5 | import fs from 'fs' 6 | import { afterAll, assert, describe, expect, it } from 'vitest' 7 | import { readAll } from '../src/shared' 8 | import { compression } from '../src' 9 | import { createDisk, getId, mockBuild } from './shared/kit.mjs' 10 | 11 | describe('plugin', () => { 12 | const { root, destroy, dir } = createDisk('plugin') 13 | afterAll(destroy) 14 | it('include only', async () => { 15 | const { output } = await mockBuild('normal', root, { plugins: [compression({ include: /\.(js)$/ })] }) 16 | expect((await readAll(output)).filter(s => s.endsWith('.gz')).length).toBe(1) 17 | }) 18 | it('exclude only', async () => { 19 | const { output } = await mockBuild('normal', root, { 20 | plugins: [compression({ exclude: /\.(html)$/, skipIfLargerOrEqual: false })] 21 | }) 22 | expect((await readAll(output)).filter(s => s.endsWith('.gz')).length).toBe(2) 23 | }) 24 | 25 | it('threshold', async () => { 26 | const { output } = await mockBuild('normal', root, { plugins: [compression({ threshold: 100 })] }) 27 | expect((await readAll(output)).filter(s => s.endsWith('.gz')).length).toBe(2) 28 | }) 29 | 30 | it('algorithm', async () => { 31 | const { output } = await mockBuild('normal', root, { 32 | plugins: [compression({ algorithm: 'gzip', skipIfLargerOrEqual: false })] 33 | }) 34 | expect((await readAll(output)).filter(s => s.endsWith('.gz')).length).toBe(3) 35 | }) 36 | it('custom alorithm', async () => { 37 | const { output } = await mockBuild('normal', root, { 38 | plugins: [compression({ 39 | algorithm(buf, opt) { 40 | return util.promisify(zlib.gzip)(buf, opt) 41 | }, 42 | skipIfLargerOrEqual: false, 43 | compressionOptions: { level: 9 } 44 | })] 45 | }) 46 | expect((await readAll(output)).filter(s => s.endsWith('.gz')).length).toBe(3) 47 | }) 48 | it('brotliCompress', async () => { 49 | const { output } = await mockBuild('normal', root, { 50 | plugins: [compression({ 51 | algorithm: 'brotliCompress', 52 | skipIfLargerOrEqual: false 53 | })] 54 | }) 55 | expect((await readAll(output)).filter(s => s.endsWith('.br')).length).toBe(3) 56 | }) 57 | it('delete original assets', async () => { 58 | const { output } = await mockBuild('normal', root, { 59 | plugins: [compression({ 60 | deleteOriginalAssets: true, 61 | skipIfLargerOrEqual: false 62 | })] 63 | }) 64 | expect((await readAll(output)).length).toBe(3) 65 | }) 66 | it('filename', async () => { 67 | const { output } = await mockBuild('normal', root, { 68 | plugins: [compression({ 69 | skipIfLargerOrEqual: false, 70 | filename: 'fake/[base].gz' 71 | })] 72 | }) 73 | const result = await readAll(path.join(output, 'fake')) 74 | expect(result.filter(s => s.endsWith('.gz')).length).toBe(3) 75 | }) 76 | 77 | it('multiple', async () => { 78 | const { output } = await mockBuild('normal', root, { 79 | plugins: [ 80 | compression({ skipIfLargerOrEqual: false, algorithm: 'gzip', include: /\.(css)$/ }), 81 | compression({ skipIfLargerOrEqual: false, include: /\.(css)$/, algorithm: 'brotliCompress' }) 82 | ] 83 | }) 84 | const result = await readAll(output) 85 | const gzip = result.filter(s => s.endsWith('.gz')) 86 | const br = result.filter(s => s.endsWith('.br')) 87 | expect(gzip.length).toBe(br.length) 88 | }) 89 | it('dynamic import source', async () => { 90 | const { output } = await mockBuild('dynamic', root, { 91 | plugins: [compression({ 92 | skipIfLargerOrEqual: false, 93 | deleteOriginalAssets: true 94 | })] 95 | }) 96 | expect((await readAll(output)).filter(s => s.endsWith('.gz')).length).toBe(4) 97 | }) 98 | 99 | describe('Public assets', () => { 100 | it('normal', async () => { 101 | const { output } = await mockBuild('public-assets', root, { 102 | plugins: [compression({ 103 | skipIfLargerOrEqual: false, 104 | deleteOriginalAssets: true, 105 | exclude: /\.(html)$/ 106 | })] 107 | }) 108 | expect((await readAll(output)).filter(s => s.endsWith('.gz')).length).toBe(3) 109 | }) 110 | it('nesting', async () => { 111 | const { output } = await mockBuild('public-assets-nest', root, { 112 | plugins: [compression({ 113 | skipIfLargerOrEqual: false, 114 | deleteOriginalAssets: true, 115 | exclude: /\.(html)$/ 116 | })] 117 | }) 118 | const result = await readAll(output) 119 | expect(result.filter(s => s.endsWith('.gz')).length).toBe(6) 120 | }) 121 | 122 | it('threshold', async () => { 123 | const { output } = await mockBuild('public-assets-nest', root, { 124 | plugins: [compression({ 125 | skipIfLargerOrEqual: false, 126 | deleteOriginalAssets: true, 127 | exclude: /\.(html)$/, 128 | threshold: 1024 * 2 129 | })] 130 | }) 131 | const result = await readAll(output) 132 | expect(result.filter(s => s.endsWith('.gz')).length).toBe(0) 133 | }) 134 | }) 135 | 136 | it('amazon s3', async () => { 137 | const { output } = await mockBuild('dynamic', root, { 138 | plugins: [compression({ 139 | skipIfLargerOrEqual: false, 140 | filename: '[path][base]', 141 | deleteOriginalAssets: true 142 | })] 143 | }) 144 | const result = await readAll(output) 145 | const cssFiles = result.filter(v => v.endsWith('.css')) 146 | assert(cssFiles.length === 1) 147 | expect(zlib.unzipSync(fs.readFileSync(cssFiles[0])).toString()).toBe( 148 | '.pr{padding-right:30px}.pl{padding-left:30px}.mt{margin-top:30px}\n' 149 | ) 150 | }) 151 | 152 | describe('rollup options', () => { 153 | it('multiple outputs', async () => { 154 | const another = path.join(dir, getId()) 155 | const another2 = path.join(dir, getId()) 156 | await mockBuild('public-assets-nest', root, { 157 | plugins: [compression({ skipIfLargerOrEqual: false, exclude: /\.(html)$/ })], 158 | build: { 159 | rollupOptions: { 160 | output: [{ dir: another }, { dir: another2 }] 161 | } 162 | } 163 | }) 164 | const result1 = await readAll(another) 165 | const result2 = await readAll(another2) 166 | assert(result1.length === result2.length) 167 | }) 168 | }) 169 | }) 170 | -------------------------------------------------------------------------------- /__tests__/shared/kit.mts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import url from 'url' 4 | import { build } from 'vite' 5 | import type { InlineConfig } from 'vite' 6 | import { destroy as _destory, create } from 'memdisk' 7 | 8 | export const __filename = url.fileURLToPath(import.meta.url) 9 | 10 | export const __dirname = path.dirname(__filename) 11 | 12 | export function getId() { 13 | return Math.random().toString(36).substring(7) 14 | } 15 | 16 | export function sleep(delay: number) { 17 | return new Promise((resolve) => setTimeout(resolve, delay)) 18 | } 19 | 20 | const namespace = 'compression-suite-' 21 | 22 | export function createDisk(p: string) { 23 | const dir = create.sync(namespace + p, 64 * 1024 * 1024, { quiet: false }) 24 | const root = dir 25 | 26 | fs.mkdirSync(root, { recursive: true }) 27 | 28 | const destroy = () => { 29 | _destory.sync(dir, { quiet: false }) 30 | } 31 | 32 | return { destroy, root, dir } 33 | } 34 | 35 | export async function mockBuild(fixture: string, dest: string, options?: InlineConfig) { 36 | const id = getId() 37 | const output = path.join(dest, id) 38 | const bundle = await build({ 39 | root: path.resolve(__dirname, '..', 'fixtures', fixture), 40 | configFile: false, 41 | logLevel: 'silent', 42 | build: { 43 | outDir: output 44 | }, 45 | ...options 46 | }) 47 | 48 | return { output, bundle } 49 | } 50 | -------------------------------------------------------------------------------- /__tests__/tarball.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import zlib from 'zlib' 3 | import { afterAll, assert, describe, expect, it } from 'vitest' 4 | import { createExtract } from 'tar-mini' 5 | import { compression, tarball } from '../src' 6 | import { createDisk, mockBuild } from './shared/kit.mjs' 7 | 8 | function extract(p: string, gz = false): Promise> { 9 | const extract = createExtract() 10 | if (gz) { 11 | fs.createReadStream(p).pipe(zlib.createUnzip()).pipe(extract.receiver) 12 | } else { 13 | fs.createReadStream(p).pipe(extract.receiver) 14 | } 15 | return new Promise((resolve, reject) => { 16 | const files: Record = {} 17 | extract.on('entry', (head, file) => { 18 | files[head.name] = Buffer.from(file) 19 | }) 20 | extract.on('finish', () => resolve(files)) 21 | extract.on('error', reject) 22 | }) 23 | } 24 | 25 | describe('tarball', () => { 26 | const { root, destroy } = createDisk('tarball') 27 | 28 | afterAll(destroy) 29 | it('only use tarball plugin', async () => { 30 | const { output, bundle } = await mockBuild('normal', root, { plugins: [tarball()] }) 31 | const extracted = await extract(output + '.tar') 32 | assert(typeof bundle === 'object' && 'output' in bundle) 33 | for (const chunk of bundle.output) { 34 | expect(extracted[chunk.fileName]).toStrictEqual(Buffer.from(chunk.type === 'asset' ? chunk.source : chunk.code)) 35 | } 36 | }) 37 | 38 | it('tar archive after compress', async () => { 39 | const { output, bundle } = await mockBuild('public-assets-nest', root, { plugins: [compression(), tarball()] }) 40 | const { output: ouput2 } = await mockBuild('public-assets-nest', root, { 41 | plugins: [compression(), tarball({ gz: true })] 42 | }) 43 | const extracted1 = await extract(output + '.tar') 44 | const extracted2 = await extract(ouput2 + '.tar.gz', true) 45 | assert(Object.keys(extracted1).length === Object.keys(extracted2).length) 46 | for (const filename in extracted1) { 47 | assert(Reflect.has(extracted2, filename)) 48 | expect(extracted1[filename]).toStrictEqual(extracted2[filename]) 49 | } 50 | assert(typeof bundle === 'object' && 'output' in bundle) 51 | for (const chunk of bundle.output) { 52 | if (chunk.fileName in extracted2) { 53 | expect(extracted2[chunk.fileName]).toStrictEqual( 54 | Buffer.from(chunk.type === 'asset' ? chunk.source : chunk.code) 55 | ) 56 | } 57 | } 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: true 3 | 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | target: 90% 9 | threshold: 2% 10 | base: auto 11 | if_ci_failed: error 12 | branches: 13 | - "master" 14 | 15 | comment: 16 | layout: "reach, diff, flags, files" 17 | behavior: default 18 | require_changes: false 19 | branches: 20 | - "master" 21 | -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "lineWidth": 140, 3 | "extends": "https://dprint.nonzzz.moe/dprint.json" 4 | } 5 | -------------------------------------------------------------------------------- /e2e/e2e.ts: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import path from 'path' 3 | import { chromium } from 'playwright' 4 | import type { Page } from 'playwright' 5 | import sirv from 'sirv' 6 | import { expect, test } from 'vitest' 7 | import { compression } from '../src' 8 | 9 | import type { Vite2Instance } from './vite2/interface' 10 | import type { Vite3Instance } from './vite3/interface' 11 | import type { Vite4Instance } from './vite4/interface' 12 | import type { Vite5Instance } from './vite5/interface' 13 | import type { Vite6Instance } from './vite6/interface' 14 | 15 | type ViteInstance = Vite2Instance | Vite3Instance | Vite4Instance | Vite5Instance | Vite6Instance 16 | 17 | type Server = http.Server & { 18 | ip: string 19 | } 20 | 21 | export interface TestOptions { 22 | vite: ViteInstance 23 | compressOption?: Parameters[number] 24 | } 25 | 26 | function createGetter(obj: T, key: string, getter: () => unknown) { 27 | Object.defineProperty(obj, key, { 28 | get: getter 29 | }) 30 | } 31 | 32 | const defaultWd = __dirname 33 | 34 | // generator assets 35 | function prepareAssets(taskName: string, options: TestOptions) { 36 | const { vite, compressOption = {} } = options 37 | return vite.build({ 38 | root: defaultWd, 39 | build: { 40 | outDir: path.join(defaultWd, 'dist', taskName) 41 | }, 42 | logLevel: 'silent', 43 | // @ts-expect-error vite type error 44 | plugins: [compression({ ...compressOption, include: [/\.(js)$/, /\.(css)$/] })] 45 | }) 46 | } 47 | 48 | function createServer(taskName: string) { 49 | const server = http.createServer() 50 | const publicPath = path.join(defaultWd, 'dist', taskName) 51 | const assets = sirv(publicPath, { gzip: true }) 52 | 53 | const handleRequest = (req: http.IncomingMessage, res: http.ServerResponse) => { 54 | assets(req, res, () => { 55 | res.statusCode = 404 56 | res.end(`404 Not Found: ${req.url}`) 57 | }) 58 | } 59 | server.on('request', handleRequest) 60 | createGetter(server, 'ip', () => { 61 | const address = server.address() 62 | if (typeof address === 'string') { return address } 63 | return `http://127.0.0.1:${address.port}` 64 | }) 65 | server.listen(0) 66 | return { server: server as Server } 67 | } 68 | 69 | async function createChromeBrowser(server: Server) { 70 | const browser = await chromium.launch() 71 | const page = await browser.newPage() 72 | const localUrl = server.ip 73 | 74 | return { page, localUrl } 75 | } 76 | 77 | async function expectTestCase(taskName: string, page: Awaited, localUrl: Awaited) { 78 | const expect1 = new Promise((resolve) => { 79 | page.on('console', (message) => resolve(message.text())) 80 | }) 81 | 82 | const expect2 = new Promise((resolve) => { 83 | page.on('console', (message) => { 84 | if (message.type() === 'log' && message.text() === 'append child') { 85 | resolve(message.text()) 86 | } 87 | }) 88 | }) 89 | 90 | test(`${taskName} page first load`, async () => { 91 | expect(await expect1).toBe('load main process') 92 | }) 93 | test(`${taskName} insert line`, async () => { 94 | await page.click('.button--insert') 95 | await page.waitForSelector('text=p-1', { timeout: 5000 }) 96 | expect(await expect2).toBe('append child') 97 | }) 98 | await page.goto(localUrl) 99 | } 100 | 101 | export async function runTest(taskName: string, options: TestOptions) { 102 | await prepareAssets(taskName, options) 103 | await new Promise((resolve) => setTimeout(resolve, 5000)) 104 | const { server } = createServer(taskName) 105 | const { page, localUrl } = await createChromeBrowser(server) 106 | await expectTestCase(taskName, page, localUrl) 107 | } 108 | -------------------------------------------------------------------------------- /e2e/fixture/dynamic.ts: -------------------------------------------------------------------------------- 1 | export function insertChildToLines() { 2 | const lines = document.querySelector('.lines') 3 | const p = document.createElement('p') 4 | const txt = lines.children.length + 1 5 | p.textContent = `p-${txt}` 6 | lines.appendChild(p) 7 | } 8 | -------------------------------------------------------------------------------- /e2e/fixture/main.ts: -------------------------------------------------------------------------------- 1 | import './theme.css' 2 | 3 | const insertButton = document.querySelector('.button--insert') 4 | 5 | insertButton.addEventListener('click', () => { 6 | import('./dynamic').then((module) => module.insertChildToLines()).catch(console.error) 7 | console.log('append child') 8 | }) 9 | 10 | console.log('load main process') 11 | -------------------------------------------------------------------------------- /e2e/fixture/theme.css: -------------------------------------------------------------------------------- 1 | p { 2 | font-size: 18px; 3 | color: red; 4 | line-height: 18px; 5 | margin: 5px 0; 6 | } 7 | -------------------------------------------------------------------------------- /e2e/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |
11 |

Welcome using vite-plugin-compression2

12 |

Insert Line

13 | 14 |
15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-compression2-e2e", 3 | "private": "true" 4 | } 5 | -------------------------------------------------------------------------------- /e2e/vite2/e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from 'vitest' 2 | import { runTest } from '../e2e' 3 | import { name } from './package.json' 4 | 5 | describe('vite2', async () => { 6 | const vite = await import('vite') 7 | await runTest(name, { 8 | vite, 9 | compressOption: { 10 | deleteOriginalAssets: true 11 | } 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /e2e/vite2/interface.ts: -------------------------------------------------------------------------------- 1 | export type Vite2Instance = typeof import('vite') 2 | -------------------------------------------------------------------------------- /e2e/vite2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-compression2-e2e-vite2", 3 | "dependencies": { 4 | "vite": "^2" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /e2e/vite3/e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from 'vitest' 2 | import { runTest } from '../e2e' 3 | import { name } from './package.json' 4 | 5 | describe('vite3', async () => { 6 | const vite = await import('vite') 7 | await runTest(name, { 8 | vite, 9 | compressOption: { 10 | deleteOriginalAssets: true 11 | } 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /e2e/vite3/interface.ts: -------------------------------------------------------------------------------- 1 | export type Vite3Instance = typeof import('vite') 2 | -------------------------------------------------------------------------------- /e2e/vite3/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-compression2-e2e-vite3", 3 | "dependencies": { 4 | "vite": "^3" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /e2e/vite4/e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from 'vitest' 2 | import { runTest } from '../e2e' 3 | import { name } from './package.json' 4 | 5 | describe('vite4', async () => { 6 | const vite = await import('vite') 7 | 8 | await runTest(name, { 9 | vite, 10 | compressOption: { 11 | deleteOriginalAssets: true 12 | } 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /e2e/vite4/interface.ts: -------------------------------------------------------------------------------- 1 | export type Vite4Instance = typeof import('vite') 2 | -------------------------------------------------------------------------------- /e2e/vite4/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-compression2-e2e-vite4", 3 | "dependencies": { 4 | "vite": "^4" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /e2e/vite5/e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from 'vitest' 2 | import { runTest } from '../e2e' 3 | import { name } from './package.json' 4 | 5 | describe('vite5', async () => { 6 | const vite = await import('vite') 7 | 8 | await runTest(name, { 9 | vite, 10 | compressOption: { 11 | deleteOriginalAssets: true 12 | } 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /e2e/vite5/interface.ts: -------------------------------------------------------------------------------- 1 | export type Vite5Instance = typeof import('vite') 2 | -------------------------------------------------------------------------------- /e2e/vite5/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-compression2-e2e-vite5", 3 | "dependencies": { 4 | "vite": "^5" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /e2e/vite6/e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from 'vitest' 2 | import { runTest } from '../e2e' 3 | import { name } from './package.json' 4 | 5 | describe('vite6', async () => { 6 | const vite = await import('vite') 7 | 8 | await runTest(name, { 9 | vite, 10 | compressOption: { 11 | deleteOriginalAssets: true 12 | } 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /e2e/vite6/interface.ts: -------------------------------------------------------------------------------- 1 | export type Vite6Instance = typeof import('vite') 2 | -------------------------------------------------------------------------------- /e2e/vite6/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-compression2-e2e-vite6", 3 | "dependencies": { 4 | "vite": "^6" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const { nonzzz } = require('eslint-config-kagura') 2 | 3 | module.exports = nonzzz({ typescript: true }, { 4 | ignores: [ 5 | '**/node_modules', 6 | '**/dist', 7 | '**/components.d.ts', 8 | '**/analysis' 9 | ] 10 | }) 11 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Vite-plugin-compression2-starter 2 | 3 | ## Step 4 | 5 | ```bash 6 | $ yarn // install dependices 7 | 8 | $ yarn build // packing static resources 9 | 10 | $ yarn preview // start a local server to preivew the result. 11 | ``` 12 | 13 | ## F & Q 14 | 15 | > How to use with others web server? 16 | 17 | - This example is very simple usage. If you're using [NGINX](https://nginx.org/en/) you can refer [document](https://nginx.org/en/docs/http/ngx_http_gzip_module.html) 18 | -------------------------------------------------------------------------------- /example/client/src/dynamic.css: -------------------------------------------------------------------------------- 1 | p { 2 | font-size: 30px; 3 | } 4 | -------------------------------------------------------------------------------- /example/client/src/dynamic.js: -------------------------------------------------------------------------------- 1 | import('./dynamic.css') 2 | 3 | export const d = 'dynamic' 4 | 5 | export function injectElement() { 6 | const app = document.querySelector('#app') 7 | 8 | const el = document.createElement('p') 9 | 10 | el.textContent = 'Dynamic Element' 11 | app.appendChild(el) 12 | } 13 | -------------------------------------------------------------------------------- /example/client/src/main.js: -------------------------------------------------------------------------------- 1 | import { seq } from './seq' 2 | 3 | const app = document.querySelector('#app') 4 | 5 | const button = document.createElement('button') 6 | 7 | button.textContent = 'Click Me' 8 | 9 | button.addEventListener('click', () => { 10 | console.log('Button Clicked') 11 | import('./dynamic').then((re) => { 12 | console.log(re.d) 13 | re.injectElement() 14 | }) 15 | }) 16 | 17 | app.appendChild(button) 18 | 19 | console.log(seq) 20 | -------------------------------------------------------------------------------- /example/client/src/seq.js: -------------------------------------------------------------------------------- 1 | export const seq = 1 2 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-compression2-example", 3 | "scripts": { 4 | "dev": "vite", 5 | "build": "vite build", 6 | "preview": "node ./server/app.js" 7 | }, 8 | "devDependencies": { 9 | "sirv": "^2.0.3", 10 | "vite": "^4.4.9", 11 | "vite-plugin-compression2": "workspace:*" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /example/public/css/x.css: -------------------------------------------------------------------------------- 1 | div { 2 | font-size: 30px; 3 | } 4 | -------------------------------------------------------------------------------- /example/public/js/hello.js: -------------------------------------------------------------------------------- 1 | console.log('vite-plugin-compression2') 2 | -------------------------------------------------------------------------------- /example/server/app.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const path = require('path') 3 | const fs = require('fs') 4 | const sirv = require('sirv') 5 | 6 | const defaultWD = process.cwd() 7 | 8 | const publicPath = path.join(defaultWD, 'dist') 9 | 10 | const assets = sirv(publicPath, { gzip: true }) 11 | 12 | function createServer() { 13 | const server = http.createServer() 14 | 15 | server.on('request', (req, res) => { 16 | assets(req, res, () => { 17 | res.statusCode = 404 18 | res.end('File not found') 19 | }) 20 | }) 21 | 22 | server.listen(0, () => { 23 | const { port } = server.address() 24 | console.log(`server run on http://localhost:${port}`) 25 | }) 26 | } 27 | 28 | function main() { 29 | if (!fs.existsSync(publicPath)) throw new Error("Please check your're already run 'npm run build'") 30 | createServer() 31 | } 32 | 33 | main() 34 | -------------------------------------------------------------------------------- /example/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { compression, tarball } from '../src' 3 | 4 | export default defineConfig({ 5 | plugins: [ 6 | compression({ 7 | include: [/\.(js)$/, /\.(css)$/], 8 | deleteOriginalAssets: true, 9 | skipIfLargerOrEqual: false 10 | }), 11 | tarball() 12 | ] 13 | }) 14 | -------------------------------------------------------------------------------- /jiek.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'jiek' 2 | 3 | export default defineConfig({ 4 | build: { 5 | output: { 6 | minify: 'only-minify' 7 | } 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-plugin-compression2", 3 | "version": "1.4.0", 4 | "packageManager": "pnpm@9.4.0", 5 | "description": "a fast vite compression plugin", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "module": "dist/index.mjs", 9 | "files": [ 10 | "dist", 11 | "README.md", 12 | "LICENSE" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/nonzzz/vite-plugin-compression.git" 17 | }, 18 | "keywords": [ 19 | "vite", 20 | "vite-plugin", 21 | "compress" 22 | ], 23 | "author": "Kanno", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/nonzzz/vite-plugin-compression/issues" 27 | }, 28 | "homepage": "https://github.com/nonzzz/vite-plugin-compression#readme", 29 | "exports": "./src/index.ts", 30 | "devDependencies": { 31 | "@types/node": "^20.14.9", 32 | "@vitest/coverage-v8": "^2.0.3", 33 | "dprint": "^0.46.3", 34 | "eslint": "^9.16.0", 35 | "eslint-config-kagura": "^3.0.1", 36 | "jiek": "^1.0.14", 37 | "memdisk": "^1.2.1", 38 | "playwright": "^1.32.3", 39 | "sirv": "^2.0.3", 40 | "typescript": "^5.3.3", 41 | "vitest": "^2.1.2", 42 | "vite": "^6.3.5", 43 | "ansis": "^4.0.0" 44 | }, 45 | "dependencies": { 46 | "@rollup/pluginutils": "^5.1.0", 47 | "tar-mini": "^0.2.0" 48 | }, 49 | "peerDependencies": { 50 | "vite": "^2.0.0||^3.0.0||^4.0.0||^5.0.0 ||^6.0.0" 51 | }, 52 | "resolutions": { 53 | "is-core-module": "npm:@nolyfill/is-core-module@^1", 54 | "sirv": "2.0.3" 55 | }, 56 | "pnpm": { 57 | "patchedDependencies": { 58 | "vite@2.9.18": "patches/vite@2.9.18.patch" 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /patches/vite@2.9.18.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/node/chunks/dep-0a035c79.js b/dist/node/chunks/dep-0a035c79.js 2 | index 57ca5f35e0ace9cacc9e61d4757ebc310fee4983..d39bfca26f674b1473e0c80a0894dc0bd4fb3cb8 100644 3 | --- a/dist/node/chunks/dep-0a035c79.js 4 | +++ b/dist/node/chunks/dep-0a035c79.js 5 | @@ -38271,7 +38271,7 @@ const isModernFlag = `__VITE_IS_MODERN__`; 6 | const preloadMethod = `__vitePreload`; 7 | const preloadMarker = `__VITE_PRELOAD__`; 8 | const preloadBaseMarker = `__VITE_PRELOAD_BASE__`; 9 | -const preloadHelperId = 'vite/preload-helper'; 10 | +const preloadHelperId = '\0vite/preload-helper'; 11 | const preloadMarkerWithQuote = `"${preloadMarker}"`; 12 | const dynamicImportPrefixRE = /import\s*\(/; 13 | /** 14 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - examples/**/* 3 | - e2e/**/* 4 | -------------------------------------------------------------------------------- /src/compress.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { createPack } from 'tar-mini' 4 | import util from 'util' 5 | import zlib from 'zlib' 6 | import type { BrotliOptions, InputType, ZlibOptions } from 'zlib' 7 | 8 | import type { Algorithm, AlgorithmFunction, UserCompressionOptions } from './interface' 9 | import { slash, stringToBytes } from './shared' 10 | 11 | export function ensureAlgorithm(userAlgorithm: Algorithm) { 12 | const algorithm = userAlgorithm in zlib ? userAlgorithm : 'gzip' 13 | return { 14 | algorithm: util.promisify(zlib[algorithm]) 15 | } 16 | } 17 | 18 | export async function compress( 19 | buf: InputType, 20 | compress: AlgorithmFunction, 21 | options: T 22 | ) { 23 | try { 24 | const res = await compress(buf, options) 25 | return res 26 | } catch (error) { 27 | return Promise.reject(error as Error) 28 | } 29 | } 30 | 31 | export const defaultCompressionOptions: { 32 | [algorithm in Algorithm]: algorithm extends 'brotliCompress' ? BrotliOptions : ZlibOptions 33 | } = { 34 | gzip: { 35 | level: zlib.constants.Z_BEST_COMPRESSION 36 | }, 37 | brotliCompress: { 38 | params: { 39 | [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY 40 | } 41 | }, 42 | deflate: { 43 | level: zlib.constants.Z_BEST_COMPRESSION 44 | }, 45 | deflateRaw: { 46 | level: zlib.constants.Z_BEST_COMPRESSION 47 | } 48 | } 49 | 50 | interface TarballOptions { 51 | dests: string[] 52 | root: string 53 | gz: boolean 54 | } 55 | 56 | interface TarballFileMeta { 57 | filename: string 58 | content: string | Uint8Array 59 | } 60 | 61 | export function createTarBall() { 62 | const pack = createPack() 63 | 64 | const wss: fs.WriteStream[] = [] 65 | 66 | const options: TarballOptions = { 67 | dests: [], 68 | root: '', 69 | gz: false 70 | } 71 | 72 | const add = (meta: TarballFileMeta) => { 73 | pack.add(stringToBytes(meta.content), { filename: meta.filename }) 74 | } 75 | 76 | const setup = (tarballOPtions: TarballOptions) => { 77 | Object.assign(options, tarballOPtions) 78 | 79 | options.dests.forEach((dest) => { 80 | const expected = slash(path.resolve(options.root, dest + '.tar' + (options.gz ? '.gz' : ''))) 81 | const parent = slash(path.dirname(expected)) 82 | if (slash(options.root) !== parent) { 83 | fs.mkdirSync(parent, { recursive: true }) 84 | } 85 | const w = fs.createWriteStream(expected) 86 | wss.push(w) 87 | }) 88 | } 89 | 90 | const done = async () => { 91 | pack.done() 92 | await Promise.all(wss.map((w) => 93 | new Promise((resolve, reject) => { 94 | w.on('error', reject) 95 | w.on('finish', resolve) 96 | if (options.gz) { 97 | pack.receiver.pipe(zlib.createGzip()).pipe(w) 98 | return 99 | } 100 | pack.receiver.pipe(w) 101 | }) 102 | )) 103 | wss.length = 0 104 | } 105 | 106 | const context = { 107 | add, 108 | setup, 109 | done 110 | } 111 | 112 | return context 113 | } 114 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createFilter } from '@rollup/pluginutils' 2 | import ansis from 'ansis' 3 | import fs from 'fs' 4 | import fsp from 'fs/promises' 5 | import os from 'os' 6 | import path from 'path' 7 | import type { Logger, Plugin, ResolvedConfig } from 'vite' 8 | import { compress, createTarBall, defaultCompressionOptions, ensureAlgorithm } from './compress' 9 | import type { 10 | Algorithm, 11 | AlgorithmFunction, 12 | GenerateBundle, 13 | Pretty, 14 | UserCompressionOptions, 15 | ViteCompressionPluginConfig, 16 | ViteCompressionPluginConfigAlgorithm, 17 | ViteCompressionPluginConfigFunction, 18 | ViteTarballPluginOptions, 19 | ViteWithoutCompressionPluginConfigFunction 20 | } from './interface' 21 | import { captureViteLogger, len, noop, readAll, replaceFileName, slash, stringToBytes } from './shared' 22 | import { createConcurrentQueue } from './task' 23 | 24 | const VITE_INTERNAL_ANALYSIS_PLUGIN = 'vite:build-import-analysis' 25 | const VITE_COMPRESSION_PLUGIN = 'vite-plugin-compression' 26 | const VITE_COPY_PUBLIC_DIR = 'copyPublicDir' 27 | const MAX_CONCURRENT = (() => { 28 | const cpus = os.cpus() || { length: 1 } 29 | if (cpus.length === 1) { return 10 } 30 | return Math.max(1, cpus.length - 1) 31 | })() 32 | 33 | interface CompressionPluginAPI { 34 | staticOutputs: Set 35 | done: Promise 36 | } 37 | 38 | interface InternalZlibOptions { 39 | algorithm: AlgorithmFunction 40 | filename: string | ((id: string) => string) 41 | options: UserCompressionOptions 42 | } 43 | 44 | function handleOutputOption(conf: ResolvedConfig) { 45 | // issue #39 46 | // In some case like vite-plugin-legacy will set an empty output item 47 | // we should skip it. 48 | 49 | // Using full path. I find if we using like `dist` or others path it can't 50 | // work on monorepo 51 | // eg: 52 | // yarn --cwd @pkg/website build 53 | const outputs: Set = new Set() 54 | const prepareAbsPath = (root: string, sub: string) => slash(path.resolve(root, sub)) 55 | if (conf.build.rollupOptions?.output) { 56 | const outputOptions = Array.isArray(conf.build.rollupOptions.output) 57 | ? conf.build.rollupOptions.output 58 | : [conf.build.rollupOptions.output] 59 | outputOptions.forEach((opt) => { 60 | if (typeof opt === 'object' && !len(Object.keys(opt))) { return } 61 | outputs.add(prepareAbsPath(conf.root, opt.dir || conf.build.outDir)) 62 | }) 63 | } else { 64 | outputs.add(prepareAbsPath(conf.root, conf.build.outDir)) 65 | } 66 | return outputs 67 | } 68 | 69 | async function handleStaticFiles(config: ResolvedConfig, callback: (file: string, assets: string) => void) { 70 | const baseCondit = VITE_COPY_PUBLIC_DIR in config.build ? config.build.copyPublicDir : true 71 | if (config.publicDir && baseCondit && fs.existsSync(config.publicDir)) { 72 | const staticAssets = await readAll(config.publicDir) 73 | const publicPath = path.join(config.root, path.relative(config.root, config.publicDir)) 74 | staticAssets.forEach((assets) => { 75 | const file = slash(path.relative(publicPath, assets)) 76 | callback(file, assets) 77 | }) 78 | } 79 | } 80 | 81 | function tarball(opts: ViteTarballPluginOptions = {}): Plugin { 82 | const { dest: userDest, gz = false } = opts 83 | const statics: string[] = [] 84 | const outputs: string[] = [] 85 | let dests: string[] = [] 86 | let root = process.cwd() 87 | const tarball = createTarBall() 88 | const queue = createConcurrentQueue(MAX_CONCURRENT) 89 | let ctx: ReturnType 90 | return { 91 | name: 'vite-plugin-tarball', 92 | enforce: 'post', 93 | async configResolved(config) { 94 | outputs.push(...handleOutputOption(config)) 95 | root = config.root 96 | dests = userDest ? [userDest] : outputs 97 | // No need to add source to pack in configResolved stage 98 | // If we do at the start stage. The build task will be slow. 99 | ctx = compression.getPluginAPI(config.plugins) 100 | if (!ctx) { 101 | await handleStaticFiles(config, (file) => { 102 | statics.push(file) 103 | }) 104 | } 105 | // create dest dir 106 | tarball.setup({ dests, root, gz }) 107 | }, 108 | writeBundle(_, bundles) { 109 | for (const fileName in bundles) { 110 | const bundle = bundles[fileName] 111 | tarball.add({ filename: fileName, content: bundle.type === 'asset' ? bundle.source : bundle.code }) 112 | } 113 | }, 114 | async closeBundle() { 115 | if (ctx) { 116 | await ctx.done 117 | } 118 | if (!statics.length && ctx && ctx.staticOutputs.size) { 119 | statics.push(...ctx.staticOutputs) 120 | } 121 | 122 | for (const dest of outputs) { 123 | for (const file of statics) { 124 | queue.enqueue(async () => { 125 | const p = path.join(dest, file) 126 | const buf = await fsp.readFile(p) 127 | tarball.add({ filename: file, content: buf }) 128 | }) 129 | } 130 | } 131 | await queue.wait() 132 | await tarball.done() 133 | } 134 | } 135 | } 136 | 137 | function hijackGenerateBundle(plugin: Plugin, afterHook: GenerateBundle) { 138 | const hook = plugin.generateBundle 139 | if (typeof hook === 'object' && hook.handler) { 140 | const fn = hook.handler 141 | hook.handler = async function handler(this, ...args: Parameters) { 142 | await fn.apply(this, args) 143 | await afterHook.apply(this, args) 144 | } 145 | } 146 | if (typeof hook === 'function') { 147 | plugin.generateBundle = async function handler(this, ...args: Parameters) { 148 | await hook.apply(this, args) 149 | await afterHook.apply(this, args) 150 | } 151 | } 152 | } 153 | 154 | function compression(): Plugin 155 | function compression< 156 | T extends UserCompressionOptions | undefined, 157 | A extends Algorithm | AlgorithmFunction | AlgorithmFunction 158 | >( 159 | opts: A extends Algorithm ? Pretty> 160 | : ViteCompressionPluginConfigFunction> 161 | ): Plugin 162 | function compression( 163 | opts: ViteCompressionPluginConfigFunction> 164 | ): Plugin 165 | function compression(opts: ViteWithoutCompressionPluginConfigFunction): Plugin 166 | function compression( 167 | opts: ViteCompressionPluginConfig = {} 168 | ): Plugin { 169 | const { 170 | include = /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml)$/, 171 | exclude, 172 | threshold = 0, 173 | algorithm: userAlgorithm = 'gzip', 174 | filename, 175 | compressionOptions, 176 | deleteOriginalAssets = false, 177 | skipIfLargerOrEqual = true 178 | } = opts 179 | 180 | const filter = createFilter(include, exclude) 181 | 182 | const statics: string[] = [] 183 | const outputs: string[] = [] 184 | 185 | // vite internal vite:reporter don't write any log info to stdout. So we only capture the built in message 186 | // and print static process to stdout. I don't want to complicate things. So about message aligned with internal 187 | // result. I never deal with it. 188 | const { msgs, cleanup } = captureViteLogger() 189 | 190 | let logger: Logger 191 | 192 | let root: string = process.cwd() 193 | 194 | const zlib: InternalZlibOptions = { 195 | algorithm: typeof userAlgorithm === 'string' ? ensureAlgorithm(userAlgorithm).algorithm : userAlgorithm, 196 | options: typeof userAlgorithm === 'function' 197 | ? compressionOptions 198 | : Object.assign({}, defaultCompressionOptions[userAlgorithm], compressionOptions), 199 | filename: filename ?? (userAlgorithm === 'brotliCompress' ? '[path][base].br' : '[path][base].gz') 200 | } 201 | 202 | const queue = createConcurrentQueue(MAX_CONCURRENT) 203 | 204 | const generateBundle: GenerateBundle = async function handler(_, bundles) { 205 | for (const fileName in bundles) { 206 | if (!filter(fileName)) { continue } 207 | const bundle = bundles[fileName] 208 | const source = stringToBytes(bundle.type === 'asset' ? bundle.source : bundle.code) 209 | const size = len(source) 210 | if (size < threshold) { continue } 211 | queue.enqueue(async () => { 212 | const name = replaceFileName(fileName, zlib.filename) 213 | const compressed = await compress(source, zlib.algorithm, zlib.options) 214 | if (skipIfLargerOrEqual && len(compressed) >= size) { return } 215 | // #issue 30 31 216 | // https://rollupjs.org/plugin-development/#this-emitfile 217 | if (deleteOriginalAssets || fileName === name) { Reflect.deleteProperty(bundles, fileName) } 218 | this.emitFile({ type: 'asset', fileName: name, source: compressed }) 219 | }) 220 | } 221 | await queue.wait().catch(this.error) 222 | } 223 | 224 | const doneResolver: { resolve: () => void } = { resolve: noop } 225 | 226 | const pluginContext: CompressionPluginAPI = { 227 | staticOutputs: new Set(), 228 | done: new Promise((resolve) => { 229 | doneResolver.resolve = resolve 230 | }) 231 | } 232 | 233 | const numberFormatter = new Intl.NumberFormat('en', { 234 | maximumFractionDigits: 2, 235 | minimumFractionDigits: 2 236 | }) 237 | 238 | const displaySize = (bytes: number) => { 239 | return `${numberFormatter.format(bytes / 1000)} kB` 240 | } 241 | 242 | const plugin = { 243 | name: VITE_COMPRESSION_PLUGIN, 244 | apply: 'build', 245 | enforce: 'post', 246 | api: pluginContext, 247 | async configResolved(config) { 248 | // hijack vite's internal `vite:build-import-analysis` plugin.So we won't need process us chunks at closeBundle anymore. 249 | // issue #26 250 | // https://github.com/vitejs/vite/blob/716286ef21f4d59786f21341a52a81ee5db58aba/packages/vite/src/node/build.ts#L566-L611 251 | // Vite follow rollup option as first and the configResolved Hook don't expose merged conf for user. :( 252 | // Someone who like using rollupOption. `config.build.outDir` will not as expected. 253 | outputs.push(...handleOutputOption(config)) 254 | // Vite's pubic build: https://github.com/vitejs/vite/blob/HEAD/packages/vite/src/node/build.ts#L704-L709 255 | // copyPublicDir minimum version 3.2+ 256 | // No need check size here. 257 | await handleStaticFiles(config, (file) => { 258 | statics.push(file) 259 | }) 260 | const viteAnalyzerPlugin = config.plugins.find((p) => p.name === VITE_INTERNAL_ANALYSIS_PLUGIN) 261 | if (!viteAnalyzerPlugin) { 262 | throw new Error("[vite-plugin-compression] Can't be work in versions lower than vite at 2.0.0") 263 | } 264 | hijackGenerateBundle(viteAnalyzerPlugin, generateBundle) 265 | 266 | logger = config.logger 267 | 268 | root = config.root 269 | }, 270 | async closeBundle() { 271 | const compressedMessages: Array<{ dest: string, file: string, size: number }> = [] 272 | 273 | const compressAndHandleFile = async (filePath: string, file: string, dest: string) => { 274 | const buf = await fsp.readFile(filePath) 275 | const compressed = await compress(buf, zlib.algorithm, zlib.options) 276 | if (skipIfLargerOrEqual && len(compressed) >= len(buf)) { 277 | if (!pluginContext.staticOutputs.has(file)) { pluginContext.staticOutputs.add(file) } 278 | return 279 | } 280 | 281 | const fileName = replaceFileName(file, zlib.filename) 282 | if (!pluginContext.staticOutputs.has(fileName)) { pluginContext.staticOutputs.add(fileName) } 283 | 284 | const outputPath = path.join(dest, fileName) 285 | if (deleteOriginalAssets && outputPath !== filePath) { 286 | await fsp.rm(filePath, { recursive: true, force: true }) 287 | } 288 | await fsp.writeFile(outputPath, compressed) 289 | compressedMessages.push({ dest: path.relative(root, dest) + '/', file: fileName, size: len(compressed) }) 290 | } 291 | 292 | const processFile = async (dest: string, file: string) => { 293 | const filePath = path.join(dest, file) 294 | if (!filter(filePath) && !pluginContext.staticOutputs.has(file)) { 295 | pluginContext.staticOutputs.add(file) 296 | return 297 | } 298 | const { size } = await fsp.stat(filePath) 299 | if (size < threshold) { 300 | if (!pluginContext.staticOutputs.has(file)) { 301 | pluginContext.staticOutputs.add(file) 302 | } 303 | return 304 | } 305 | await compressAndHandleFile(filePath, file, dest) 306 | } 307 | 308 | // parallel run 309 | for (const dest of outputs) { 310 | for (const file of statics) { 311 | queue.enqueue(() => processFile(dest, file)) 312 | } 313 | } 314 | 315 | // issue #18 316 | // In somecase. Like vuepress it will called vite build with `Promise.all`. But it's concurrency. when we record the 317 | // file fd. It had been changed. So that we should catch the error 318 | await queue.wait().catch((e: unknown) => e) 319 | doneResolver.resolve() 320 | cleanup() 321 | 322 | if (logger) { 323 | const paddingSize = compressedMessages.reduce((acc, cur) => { 324 | const full = cur.dest + cur.file 325 | return Math.max(acc, full.length) 326 | }, 0) 327 | 328 | for (const { dest, file, size } of compressedMessages) { 329 | const paddedFile = file.padEnd(paddingSize) 330 | logger.info(ansis.dim(dest) + ansis.green(paddedFile) + ansis.bold(ansis.dim(displaySize(size)))) 331 | } 332 | } 333 | 334 | for (const msg of msgs) { 335 | console.info(msg) 336 | } 337 | } 338 | } 339 | 340 | return plugin 341 | } 342 | 343 | compression.getPluginAPI = (plugins: readonly Plugin[]): CompressionPluginAPI | undefined => 344 | (plugins.find((p) => p.name === VITE_COMPRESSION_PLUGIN) as Plugin)?.api 345 | 346 | function defineCompressionOption(option: ViteCompressionPluginConfig) { 347 | return option 348 | } 349 | 350 | export { compression, defineCompressionOption, tarball } 351 | 352 | export default compression 353 | 354 | export type { 355 | Algorithm, 356 | CompressionOptions, 357 | ViteCompressionPluginConfig, 358 | ViteCompressionPluginOption, 359 | ViteTarballPluginOptions 360 | } from './interface' 361 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | import type { FilterPattern } from '@rollup/pluginutils' 2 | import type { HookHandler, Plugin } from 'vite' 3 | import type { BrotliOptions, InputType, ZlibOptions } from 'zlib' 4 | 5 | export type Algorithm = 'gzip' | 'brotliCompress' | 'deflate' | 'deflateRaw' 6 | 7 | export interface UserCompressionOptions { 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | [key: string]: any 10 | } 11 | 12 | export type InferDefault = T extends infer K ? K : UserCompressionOptions 13 | 14 | export type CompressionOptions = InferDefault 15 | 16 | export type Pretty = 17 | & { 18 | [key in keyof T]: T[key] 19 | } 20 | & NonNullable 21 | 22 | interface BaseCompressionPluginOptions { 23 | include?: FilterPattern 24 | exclude?: FilterPattern 25 | threshold?: number 26 | filename?: string | ((id: string) => string) 27 | deleteOriginalAssets?: boolean 28 | skipIfLargerOrEqual?: boolean 29 | } 30 | interface AlgorithmToZlib { 31 | gzip: ZlibOptions 32 | brotliCompress: BrotliOptions 33 | deflate: ZlibOptions 34 | deflateRaw: ZlibOptions 35 | } 36 | 37 | export type AlgorithmFunction = (buf: InputType, options: T) => Promise 38 | 39 | type InternalCompressionPluginOptionsFunction> = { 40 | algorithm?: A, 41 | compressionOptions: T 42 | } 43 | type InternalWithoutCompressionPluginOptionsFunction = { 44 | algorithm?: AlgorithmFunction 45 | } 46 | type InternalCompressionPluginOptionsAlgorithm = { 47 | algorithm?: A, 48 | compressionOptions?: Pretty 49 | } 50 | 51 | export type ViteCompressionPluginConfigFunction> = 52 | & BaseCompressionPluginOptions 53 | & InternalCompressionPluginOptionsFunction 54 | export type ViteWithoutCompressionPluginConfigFunction = Pretty< 55 | & BaseCompressionPluginOptions 56 | & InternalWithoutCompressionPluginOptionsFunction 57 | > 58 | export type ViteCompressionPluginConfigAlgorithm = 59 | & BaseCompressionPluginOptions 60 | & InternalCompressionPluginOptionsAlgorithm 61 | export type ViteCompressionPluginConfig = 62 | | ViteCompressionPluginConfigFunction> 63 | | ViteCompressionPluginConfigAlgorithm 64 | 65 | export type ViteCompressionPluginOption = A extends undefined 66 | ? Pretty 67 | : A extends Algorithm ? Pretty> 68 | : A extends UserCompressionOptions ? Pretty>> 69 | : never 70 | 71 | export type GenerateBundle = HookHandler 72 | 73 | export type WriteBundle = HookHandler 74 | 75 | export interface ViteTarballPluginOptions { 76 | dest?: string 77 | gz?: boolean 78 | } 79 | -------------------------------------------------------------------------------- /src/shared.ts: -------------------------------------------------------------------------------- 1 | import fsp from 'fs/promises' 2 | import path from 'path' 3 | 4 | export function len>(source: T) { 5 | return source.length 6 | } 7 | 8 | // [path][base].ext 9 | // [path] is replaced with the directories to the original asset, included trailing 10 | // [base] is replaced with the base ([name] + [ext]) of the original asset (image.png) 11 | export function replaceFileName(staticPath: string, rule: string | ((id: string) => string)) { 12 | const template = typeof rule === 'function' ? rule(staticPath) : rule 13 | const { dir, base } = path.parse(staticPath) 14 | const p = dir ? dir + '/' : '' 15 | return template.replace(/\[path\]/, p).replace(/\[base\]/, base) 16 | } 17 | 18 | export function slash(path: string) { 19 | const isExtendedLengthPath = /^\\\\\?\\/.test(path) 20 | if (isExtendedLengthPath) { return path } 21 | return path.replace(/\\/g, '/') 22 | } 23 | 24 | export async function readAll(entry: string) { 25 | const paths = await Promise.all((await fsp.readdir(entry)).map((dir) => path.join(entry, dir))) 26 | let pos = 0 27 | const result: string[] = [] 28 | while (pos !== len(paths)) { 29 | const dir = paths[pos] 30 | const stat = await fsp.stat(dir) 31 | if (stat.isDirectory()) { 32 | const dirs = await fsp.readdir(dir) 33 | paths.push(...dirs.map((sub) => path.join(dir, sub))) 34 | } 35 | if (stat.isFile()) { 36 | result.push(dir) 37 | } 38 | pos++ 39 | } 40 | return result 41 | } 42 | 43 | const encoder = new TextEncoder() 44 | 45 | export function stringToBytes(b: string | Uint8Array) { 46 | return typeof b === 'string' ? encoder.encode(b) : b 47 | } 48 | 49 | export function noop() {} 50 | 51 | export function captureViteLogger() { 52 | const msgs: string[] = [] 53 | 54 | const originalStdWrite = process.stdout.write.bind(process.stdout) as typeof process.stdout.write 55 | 56 | const cleanup = () => process.stdout.write = originalStdWrite 57 | 58 | // @ts-expect-error overloaded methods 59 | process.stdout.write = function(...args: Parameters) { 60 | const [output] = args 61 | const str = typeof output === 'string' ? output : output.toString() 62 | if (str.includes('built in')) { 63 | msgs.push(str) 64 | return false 65 | } 66 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 67 | return originalStdWrite.apply(this, args) 68 | } 69 | 70 | return { cleanup, msgs } 71 | } 72 | -------------------------------------------------------------------------------- /src/task.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-floating-promises */ 2 | import { len } from './shared' 3 | 4 | class Queue { 5 | maxConcurrent: number 6 | queue: Array<() => Promise> 7 | running: number 8 | errors: Array 9 | constructor(maxConcurrent: number) { 10 | this.maxConcurrent = maxConcurrent 11 | this.queue = [] 12 | this.errors = [] 13 | this.running = 0 14 | } 15 | 16 | enqueue(task: () => Promise) { 17 | this.queue.push(task) 18 | this.run() 19 | } 20 | 21 | async run() { 22 | while (this.running < this.maxConcurrent && this.queue.length) { 23 | const task = this.queue.shift() 24 | this.running++ 25 | try { 26 | await task() 27 | } catch (error) { 28 | this.errors.push(error as Error) 29 | } finally { 30 | this.running-- 31 | this.run() 32 | } 33 | } 34 | } 35 | 36 | async wait() { 37 | while (this.running) { 38 | await new Promise((resolve) => setTimeout(resolve, 0)) 39 | } 40 | if (len(this.errors)) { throw new AggregateError(this.errors, 'task failed') } 41 | } 42 | } 43 | 44 | export function createConcurrentQueue(max: number) { 45 | return new Queue(max) 46 | } 47 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": ".", 5 | "noImplicitThis": true, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "target": "ES2019", 9 | "lib": ["esnext", "dom"], 10 | "resolveJsonModule": true, 11 | "module": "ESNext", 12 | "moduleResolution": "node", 13 | "outDir": "dist" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | watch: false, 6 | coverage: { 7 | enabled: true, 8 | provider: 'v8', 9 | reporter: ['text', 'json', 'html'], 10 | include: ['src/**'], 11 | exclude: ['**/node_modules/**', '**/dist/**', 'src/**/interface.ts'] 12 | }, 13 | testTimeout: 8000 14 | } 15 | }) 16 | --------------------------------------------------------------------------------