├── .gitignore ├── zopfliAdapter.js ├── brotliAdapter.js ├── package.json ├── LICENSE ├── formatter.js ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | /node_modules 3 | /.project 4 | /.idea/ 5 | -------------------------------------------------------------------------------- /zopfliAdapter.js: -------------------------------------------------------------------------------- 1 | const zopfliAdapter = () => { 2 | try { 3 | const zopfli = require('node-zopfli-es'); 4 | return zopfli; 5 | } catch (err) { 6 | const zopfli = require('@gfx/zopfli'); 7 | return zopfli; 8 | } 9 | } 10 | 11 | module.exports = zopfliAdapter 12 | -------------------------------------------------------------------------------- /brotliAdapter.js: -------------------------------------------------------------------------------- 1 | const brotliAdapter = () => { 2 | const zlib = require('zlib'); 3 | if (zlib.brotliCompressSync) { 4 | return { isZlib: true, compress: zlib.brotliCompressSync }; 5 | } else { 6 | const brotli = require('brotli'); 7 | return brotli; 8 | } 9 | }; 10 | 11 | module.exports = brotliAdapter; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parcel-plugin-compress", 3 | "description": "Parcel plugin that pre-compresses resources with Brotli and Gzip", 4 | "version": "2.0.2", 5 | "license": "MIT", 6 | "repository": "https://github.com/ralscha/parcel-plugin-compress", 7 | "author": "Ralph Schaer ", 8 | "contributors": [ 9 | "Mike Engel ", 10 | "Aaron Schaef " 11 | ], 12 | "keywords": [ 13 | "parcel", 14 | "zopfli", 15 | "brotli", 16 | "gzip" 17 | ], 18 | "main": "index.js", 19 | "dependencies": { 20 | "@gfx/zopfli": "1.0.15", 21 | "brotli": "1.3.2", 22 | "chalk": "4.1.2", 23 | "cosmiconfig": "7.0.1", 24 | "filesize": "6.3.0", 25 | "grapheme-breaker": "0.3.2", 26 | "p-queue": "7.1.0", 27 | "strip-ansi": "7.0.1" 28 | }, 29 | "optionalDependencies": { 30 | "node-zopfli-es": "1.0.7" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ralph Schaer 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 | -------------------------------------------------------------------------------- /formatter.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { countBreaks } = require('grapheme-breaker'); 3 | const stripAnsi = require('strip-ansi'); 4 | const chalk = require('chalk'); 5 | const filesize = require('filesize'); 6 | 7 | const columns = [{ align: 'left' }, { align: 'right' }, { align: 'right' }]; 8 | 9 | // Several functions in here were taken from parcel's official Logging class 10 | // https://github.com/parcel-bundler/parcel/blob/2aa72153509a72c0fd247b3e94495071ae61f717/src/Logger.js#L154 11 | function pad(text, length, align = 'left') { 12 | let pad = ' '.repeat(length - stringWidth(text)); 13 | if (align === 'right') { 14 | return pad + text; 15 | } 16 | 17 | return text + pad; 18 | } 19 | 20 | function stringWidth(string) { 21 | return countBreaks(stripAnsi('' + string)); 22 | } 23 | 24 | function table(table) { 25 | // Measure column widths 26 | let colWidths = []; 27 | 28 | table.forEach((row) => { 29 | row.forEach((item, idx) => { 30 | colWidths[idx] = Math.max(colWidths[idx] || 0, stringWidth(item)); 31 | }) 32 | }); 33 | 34 | // Render rows 35 | table.forEach((row) => { 36 | let items = row.map((item, i) => { 37 | // Add padding between columns unless the alignment is the opposite to the 38 | // next column and pad to the column width. 39 | let padding = 40 | !columns[i + 1] || columns[i + 1].align === columns[i].align ? 4 : 0; 41 | return pad(item, colWidths[i] + padding, columns[i].align); 42 | }); 43 | 44 | console.log(items.join('')); 45 | }); 46 | } 47 | 48 | function sortResults(a, b) { 49 | const aSize = a.size; 50 | const bSize = b.size; 51 | const aFile = a.file; 52 | const bFile = b.file; 53 | 54 | if (aSize > bSize) { 55 | return -1; 56 | } 57 | 58 | if (aSize < bSize) { 59 | return 1; 60 | } 61 | 62 | if (aSize === bSize) { 63 | if (aFile < bFile) { 64 | return -1; 65 | } 66 | 67 | if (aFile > bFile) { 68 | return 1; 69 | } 70 | 71 | return 0; 72 | } 73 | } 74 | 75 | function formatResults(result) { 76 | return [ 77 | path.relative(process.cwd(), result.file).replace(/(.+)\/(.+)$/, (_, p1, p2) => chalk.gray(`${p1}/`) + chalk.bold.cyan(p2)), 78 | chalk.bold.magenta(filesize(result.size)), 79 | chalk.bold.green(result.time / 1000 + 's'), 80 | ] 81 | } 82 | 83 | module.exports = { 84 | table, 85 | pad, 86 | stringWidth, 87 | sortResults, 88 | formatResults, 89 | }; 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### This is a Parcel 1 plugin. 2 | ### Parcel 2 has built-in support for compressing assets: https://parceljs.org/features/production/#compression 3 | 4 | ------ 5 | 6 | Parcel 1 plugin that precompresses all assets in production mode. 7 | 8 | This plugin utilizes [@gfx/zopfli](https://github.com/gfx/universal-zopfli-js), [node-zopfli-es](https://github.com/jaeh/node-zopfli-es) and [zlib](https://nodejs.org/dist/latest-v13.x/docs/api/zlib.html) for GZip compression 9 | and [zlib](https://nodejs.org/dist/latest-v13.x/docs/api/zlib.html) (Node 11.7.0+) and [brotli](https://www.npmjs.com/package/brotli) for Brotli compression. 10 | 11 | 12 | ## Installation 13 | 14 | ``` 15 | npm install parcel-plugin-compress -D 16 | ``` 17 | 18 | 19 | ## Usage 20 | 21 | By default, this plugin doesn't require any extra configuration to get started. If, however, you'd like to be more targeted in how this plugin is applied, you can configure the plugin as needed. 22 | 23 | To configure, add a file called `.compressrc` in your project's root folder, or add a key in your `package.json` called `compress`. The available options are below, with the defaults. 24 | 25 | ```js 26 | { 27 | // a regular expression to test filenames against 28 | "test": ".", 29 | // a number that represents the minimum filesize to compress, in bytes 30 | "threshold": undefined, 31 | // Concurrency limit for p-queue 32 | concurrency: 2, 33 | // configuration options for gzip compression 34 | "gzip": { 35 | "enabled": true, 36 | "numiterations": 15, 37 | "blocksplitting": true, 38 | "blocksplittinglast": false, 39 | "blocksplittingmax": 15, 40 | // use zlib instead of zopfli if zlib is true 41 | "zlib": false, 42 | "zlibLevel": 9, 43 | "zlibMemLevel": 9 44 | }, 45 | // configuration options for brotli compress 46 | "brotli": { 47 | "enabled": true, 48 | "mode": 0, // 0 = generic, 1 = text, 2 = font (used in WOFF 2.0) 49 | "quality": 11, // 0 - 11, 11 = best 50 | "lgwin": 24, // 10 - 24 51 | "enable_context_modeling": true, // disabling decreases compression ratio in favour of decompression speed 52 | "lgblock": undefined, // 16 - 24 53 | "nPostfix": undefined, // 0 - 3 54 | "nDirect": undefined // 0 to (15 << nPostfix) in steps of (1 << nPostfix) 55 | }, 56 | // a flag that changes the behavior of the plugin, by default this option is disabled 57 | // and the plugin compresses all the files it receives via the Parcel bundle object 58 | // and match the test regular expression 59 | // 60 | // if true the plugin compresses all files in the output directory and subdirectories 61 | // that match the test regular expression 62 | compressOutput: false 63 | } 64 | ``` 65 | 66 | 67 | ## Browser Support for Brotli 68 | 69 | Current versions of the major browsers send `br` in the `Accept-Encoding` header when the request is sent over TLS 70 | 71 | Support introduced in version ... 72 | 73 | * Edge 15 74 | * Firefox 44 75 | * Chrome 50 76 | * Safari 11 77 | 78 | 79 | ## Server support 80 | 81 | To take advantage of precompressed resources you need a server that is able to understand the `Accept-Encoding` header and serve files ending with `.gz` and `.br` accordingly. 82 | 83 | #### Nginx 84 | Nginx supports Gzip compressed files out of the box with the `gzip_static` directive. 85 | 86 | Add this to a `http`, `server` or `location` section and Nginx will automatically search for files ending with .gz when the request contains an `Accept-Encoding` header with the value `gzip`. 87 | ``` 88 | gzip_static on; 89 | ``` 90 | See the [documentation](http://nginx.org/en/docs/http/ngx_http_gzip_static_module.html) for more information. 91 | 92 | To enable Brotli support you either 93 | * build the [ngx_brotli](https://github.com/google/ngx_brotli) from source: 94 | https://www.majlovesreg.one/adding-brotli-to-a-built-nginx-instance 95 | * or install a pre-built Nginx from ppa with the brotli module included: 96 | https://gablaxian.com/blog/brotli-compression 97 | * or use the approach described in this blog post that works without the brotli module: 98 | https://siipo.la/blog/poor-mans-brotli-serving-brotli-files-without-nginx-brotli-module 99 | 100 | 101 | #### Apache HTTP 102 | https://css-tricks.com/brotli-static-compression/ 103 | https://blog.desgrange.net/post/2017/04/10/pre-compression-with-gzip-and-brotli-in-apache.html 104 | 105 | 106 | #### LightSpeed 107 | Support for Brotli introduced in version [5.2](https://www.litespeedtech.com/products/litespeed-web-server/release-log) 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { default: pQueue } = require('p-queue'); 4 | const { cosmiconfig } = require('cosmiconfig'); 5 | const chalk = require('chalk'); 6 | const zopfliAdapter = require('./zopfliAdapter'); 7 | const brotliAdapter = require('./brotliAdapter'); 8 | const zlib = require('zlib'); 9 | const { table, sortResults, formatResults } = require('./formatter'); 10 | 11 | const brotli = brotliAdapter(); 12 | const zopfli = zopfliAdapter(); 13 | 14 | const defaultOptions = { 15 | test: '.', 16 | compressOutput: false, 17 | threshold: undefined, 18 | concurrency: 2, 19 | gzip: { 20 | enabled: true, 21 | numiterations: 15, 22 | blocksplitting: true, 23 | blocksplittinglast: false, 24 | blocksplittingmax: 15, 25 | zlib: false, 26 | zlibLevel: zlib.constants.Z_BEST_COMPRESSION, 27 | zlibMemLevel: zlib.constants.Z_BEST_COMPRESSION 28 | }, 29 | brotli: { 30 | enabled: true, 31 | mode: 0, 32 | quality: 11, 33 | lgwin: 24, 34 | enable_context_modeling: true 35 | } 36 | }; 37 | 38 | let output = []; 39 | 40 | module.exports = bundler => { 41 | bundler.on('bundled', async (bundle) => { 42 | if (process.env.NODE_ENV === 'production') { 43 | const start = new Date().getTime(); 44 | console.log(chalk.bold('\n🗜️ Compressing bundled files...\n')); 45 | 46 | try { 47 | const explorer = cosmiconfig('compress'); 48 | const { config: { gzip, brotli, test, threshold, compressOutput } } = (await explorer.search()) || { config: defaultOptions }; 49 | 50 | const fileTest = new RegExp(test); 51 | let filesToCompress; 52 | let input; 53 | 54 | if (compressOutput === true) { 55 | input = bundle.entryAsset.options.outDir; 56 | filesToCompress = function* (dir) { 57 | const files = fs.readdirSync(dir); 58 | for (const file of files) { 59 | const f = path.join(dir, file); 60 | if (fs.statSync(f).isDirectory()) { 61 | try { 62 | yield* filesToCompress(f); 63 | } catch (error) { 64 | continue; 65 | } 66 | } else { 67 | if (fileTest.test(f)) { 68 | yield f; 69 | } 70 | } 71 | } 72 | } 73 | } else { 74 | input = bundle; 75 | filesToCompress = function* (bundle) { 76 | if (bundle.name && fileTest.test(bundle.name)) { 77 | yield bundle.name 78 | } 79 | for (var child of bundle.childBundles) { 80 | yield* filesToCompress(child) 81 | } 82 | } 83 | } 84 | 85 | const queue = new pQueue({ concurrency: defaultOptions.concurrency }); 86 | [...filesToCompress(input)].forEach(file => { 87 | queue.add(() => gzipCompress(file, { ...defaultOptions.gzip, threshold, ...gzip })); 88 | queue.add(() => brotliCompress(file, { ...defaultOptions.brotli, threshold, ...brotli })); 89 | }); 90 | 91 | await queue.onIdle(); 92 | 93 | const end = new Date().getTime(); 94 | const formattedOutput = output.sort(sortResults).map(formatResults); 95 | 96 | console.log(chalk.bold.green(`\n✨ Compressed in ${((end - start) / 1000).toFixed(2)}s.\n`)); 97 | 98 | table(formattedOutput); 99 | } catch (err) { 100 | console.error(chalk.bold.red('❌ Compression error:\n'), err); 101 | } 102 | } 103 | }); 104 | 105 | function gzipCompress(file, config) { 106 | if (!config.enabled) { 107 | return Promise.resolve(); 108 | } 109 | 110 | let stat; 111 | 112 | try { 113 | stat = fs.statSync(file); 114 | } catch (err) { 115 | return Promise.resolve(); 116 | } 117 | 118 | const start = new Date().getTime(); 119 | 120 | if (!stat.isFile()) { 121 | return Promise.resolve(); 122 | } 123 | 124 | if (config.threshold && stat.size < config.threshold) { 125 | return Promise.resolve(); 126 | } 127 | 128 | return new Promise((resolve, reject) => { 129 | fs.readFile(file, function (err, content) { 130 | if (err) { return reject(err); } 131 | 132 | if (config.zlib) { 133 | zlib.gzip(content, { level: config.zlibLevel, memLevel: config.zlibMemLevel }, handleCompressedData.bind(this, resolve, reject)); 134 | } else { 135 | zopfli.gzip(content, config, handleCompressedData.bind(this, resolve, reject)); 136 | } 137 | }); 138 | }); 139 | 140 | 141 | function handleCompressedData(resolve, reject, err, compressedContent) { 142 | if (err) { return reject(err); } 143 | 144 | if (stat.size > compressedContent.length) { 145 | const fileName = file + '.gz'; 146 | 147 | fs.writeFile(fileName, compressedContent, () => { 148 | const end = new Date().getTime(); 149 | 150 | output.push({ size: compressedContent.length, file: fileName, time: end - start }); 151 | 152 | return resolve(); 153 | }); 154 | } else { 155 | resolve(); 156 | } 157 | } 158 | } 159 | 160 | function brotliCompress(file, config) { 161 | if (!config.enabled) { 162 | return Promise.resolve(); 163 | } 164 | 165 | let stat; 166 | 167 | try { 168 | stat = fs.statSync(file); 169 | } catch (err) { 170 | return Promise.resolve(); 171 | } 172 | 173 | const start = new Date().getTime(); 174 | 175 | if (!stat.isFile()) { 176 | return Promise.resolve(); 177 | } 178 | 179 | if (config.threshold && stat.size < config.threshold) { 180 | return Promise.resolve(); 181 | } 182 | 183 | if (brotli.isZlib) { 184 | let brotliMode; 185 | if (config.mode === 1) { 186 | brotliMode = zlib.constants.BROTLI_MODE_TEXT; 187 | } 188 | else if (config.mode === 2) { 189 | brotliMode = zlib.constants.BROTLI_MODE_FONT; 190 | } 191 | else { 192 | brotliMode = zlib.constants.BROTLI_MODE_GENERIC; 193 | } 194 | config = { 195 | params: { 196 | [zlib.constants.BROTLI_PARAM_MODE]: brotliMode, 197 | [zlib.constants.BROTLI_PARAM_QUALITY]: config.quality, 198 | [zlib.constants.BROTLI_PARAM_SIZE_HINT]: stat.size, 199 | [zlib.constants.BROTLI_PARAM_LGWIN]: config.lgwin, 200 | [zlib.constants.BROTLI_PARAM_DISABLE_LITERAL_CONTEXT_MODELING]: (config.enable_context_modeling === false ? true : false) 201 | } 202 | }; 203 | if (config.lgblock) { 204 | config.params[zlib.constants.BROTLI_PARAM_LGBLOCK] = config.lgblock; 205 | } 206 | if (config.nPostfix) { 207 | config.params[zlib.constants.BROTLI_PARAM_NPOSTFIX] = config.nPostfix; 208 | } 209 | if (config.nDirect) { 210 | config.params[zlib.constants.BROTLI_PARAM_NDIRECT] = config.nDirect; 211 | } 212 | } 213 | 214 | return new Promise((resolve, reject) => { 215 | fs.readFile(file, (err, content) => { 216 | if (err) { return reject(err); } 217 | 218 | const compressedContent = brotli.compress(content, config); 219 | 220 | if (compressedContent !== null && stat.size > compressedContent.length) { 221 | const fileName = file + '.br'; 222 | 223 | fs.writeFile(fileName, compressedContent, () => { 224 | const end = new Date().getTime(); 225 | 226 | output.push({ size: compressedContent.length, file: fileName, time: end - start }); 227 | 228 | return resolve(); 229 | }); 230 | } else { 231 | resolve(); 232 | } 233 | }); 234 | }); 235 | } 236 | }; 237 | 238 | 239 | 240 | 241 | --------------------------------------------------------------------------------