├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── main.yml │ └── pull_request.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── benchmark └── index.mjs ├── package.json ├── src └── index.js └── test ├── unit ├── brotli.js ├── gzip.js ├── index.js └── level.js └── util └── index.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | max_line_length = 120 13 | indent_brace_style = 1TBS 14 | spaces_around_operators = true 15 | quote_type = auto 16 | 17 | [package.json] 18 | indent_style = space 19 | indent_size = 2 20 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: 'github-actions' 8 | directory: '/' 9 | schedule: 10 | # Check for updates to GitHub Actions every weekday 11 | interval: 'daily' 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | contributors: 10 | if: "${{ github.event.head_commit.message != 'build: contributors' }}" 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: lts/* 22 | - name: Contributors 23 | run: | 24 | git config --global user.email ${{ secrets.GIT_EMAIL }} 25 | git config --global user.name ${{ secrets.GIT_USERNAME }} 26 | npm run contributors 27 | - name: Push changes 28 | run: | 29 | git push origin ${{ github.head_ref }} 30 | 31 | test: 32 | if: | 33 | !startsWith(github.event.head_commit.message, 'chore(release):') && 34 | !startsWith(github.event.head_commit.message, 'docs:') && 35 | !startsWith(github.event.head_commit.message, 'ci:') 36 | needs: [contributors] 37 | runs-on: ubuntu-latest 38 | strategy: 39 | matrix: 40 | version: 41 | - node: 18 42 | - node: 20 43 | - node: 22 44 | name: Node.js ${{ matrix.version.node }} 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v4 48 | with: 49 | token: ${{ secrets.GITHUB_TOKEN }} 50 | - name: Setup Node.js 51 | uses: actions/setup-node@v4 52 | with: 53 | node-version: lts/* 54 | - name: Setup PNPM 55 | uses: pnpm/action-setup@v4 56 | with: 57 | version: latest 58 | run_install: true 59 | - name: Test 60 | run: npm test 61 | - name: Report 62 | run: npx c8 report --reporter=text-lcov > coverage/lcov.info 63 | - name: Coverage 64 | uses: coverallsapp/github-action@main 65 | with: 66 | github-token: ${{ secrets.GITHUB_TOKEN }} 67 | 68 | release: 69 | if: | 70 | !startsWith(github.event.head_commit.message, 'chore(release):') && 71 | !startsWith(github.event.head_commit.message, 'docs:') && 72 | !startsWith(github.event.head_commit.message, 'ci:') 73 | needs: [contributors, test] 74 | runs-on: ubuntu-latest 75 | steps: 76 | - name: Checkout 77 | uses: actions/checkout@v4 78 | with: 79 | token: ${{ secrets.GITHUB_TOKEN }} 80 | - name: Setup Node.js 81 | uses: actions/setup-node@v4 82 | with: 83 | node-version: lts/* 84 | - name: Setup PNPM 85 | uses: pnpm/action-setup@v4 86 | with: 87 | version: latest 88 | run_install: true 89 | - name: Release 90 | env: 91 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 92 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 93 | run: | 94 | git config --global user.email ${{ secrets.GIT_EMAIL }} 95 | git config --global user.name ${{ secrets.GIT_USERNAME }} 96 | git pull origin master 97 | npm run release 98 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: pull_request 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | if: github.ref != 'refs/heads/master' 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | version: 18 | - node: 18 19 | - node: 20 20 | - node: 22 21 | name: Node.js ${{ matrix.version.node }} 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | with: 26 | token: ${{ secrets.GITHUB_TOKEN }} 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: ${{ matrix.node }} 31 | - name: Setup PNPM 32 | uses: pnpm/action-setup@v4 33 | with: 34 | version: latest 35 | run_install: true 36 | - name: Test 37 | run: npm test 38 | - name: Report 39 | run: npx c8 report --reporter=text-lcov > coverage/lcov.info 40 | - name: Coverage 41 | uses: coverallsapp/github-action@main 42 | with: 43 | github-token: ${{ secrets.GITHUB_TOKEN }} 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ############################ 2 | # npm 3 | ############################ 4 | node_modules 5 | npm-debug.log 6 | .node_history 7 | yarn.lock 8 | package-lock.json 9 | 10 | ############################ 11 | # tmp, editor & OS files 12 | ############################ 13 | .tmp 14 | *.swo 15 | *.swp 16 | *.swn 17 | *.swm 18 | .DS_Store 19 | *# 20 | *~ 21 | .idea 22 | *sublime* 23 | nbproject 24 | 25 | ############################ 26 | # Tests 27 | ############################ 28 | testApp 29 | coverage 30 | .nyc_output 31 | 32 | ############################ 33 | # Other 34 | ############################ 35 | .env 36 | .envrc 37 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-prefix=~ 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### 1.1.1 (2024-10-07) 6 | 7 | ## 1.1.0 (2024-10-07) 8 | 9 | 10 | ### Features 11 | 12 | * compression level is an object ([#15](https://github.com/Kikobeats/http-compression/issues/15)) ([6501434](https://github.com/Kikobeats/http-compression/commit/650143407d922d35614234c4dad637922b5055d8)) 13 | 14 | ### 1.0.21 (2024-10-07) 15 | 16 | ### 1.0.20 (2024-05-07) 17 | 18 | ### 1.0.19 (2024-02-08) 19 | 20 | ### 1.0.18 (2024-02-08) 21 | 22 | ### 1.0.17 (2023-10-23) 23 | 24 | ### 1.0.16 (2023-10-08) 25 | 26 | ### 1.0.15 (2023-09-25) 27 | 28 | ### 1.0.14 (2023-09-24) 29 | 30 | ### 1.0.13 (2023-09-23) 31 | 32 | ### 1.0.12 (2023-09-23) 33 | 34 | ### 1.0.11 (2023-09-07) 35 | 36 | ### 1.0.10 (2023-07-07) 37 | 38 | ### 1.0.9 (2023-07-05) 39 | 40 | ### 1.0.8 (2023-07-05) 41 | 42 | ### 1.0.7 (2023-05-22) 43 | 44 | ### 1.0.6 (2023-05-16) 45 | 46 | ### 1.0.5 (2023-03-28) 47 | 48 | ### 1.0.4 (2023-01-26) 49 | 50 | ### 1.0.3 (2023-01-26) 51 | 52 | ### 1.0.2 (2023-01-25) 53 | 54 | ### 1.0.1 (2023-01-18) 55 | 56 | 57 | ### Bug Fixes 58 | 59 | * release step ([12a3def](https://github.com/Kikobeats/http-compression/commit/12a3defb7b99bfc23fdbe91fc0ebf307e3037c59)) 60 | 61 | ## 1.0.0 (2023-01-18) 62 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2023 Kiko Beats (kikobeats.com) 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # http-compression 2 | 3 | ![Last version](https://img.shields.io/github/tag/Kikobeats/http-compression.svg?style=flat-square) 4 | [![Coverage Status](https://img.shields.io/coveralls/Kikobeats/http-compression.svg?style=flat-square)](https://coveralls.io/github/Kikobeats/http-compression) 5 | [![NPM Status](https://img.shields.io/npm/dm/http-compression.svg?style=flat-square)](https://www.npmjs.org/package/http-compression) 6 | 7 | **http-compression** adds compression for your HTTP server in Node.js by: 8 | 9 | - No dependencies (< 1kB). 10 | - Express style middleware support. 11 | - Auto detect the best encoding to use (gzip/brotli). 12 | 13 | ## Install 14 | 15 | ```bash 16 | $ npm install http-compression --save 17 | ``` 18 | 19 | ## Usage 20 | 21 | If you are using an Express style framework, you can add it as middlware: 22 | 23 | ```js 24 | const compression = require('http-compression') 25 | const express = require('express') 26 | 27 | express() 28 | .use(compression({ /* see options below */ })) 29 | .use((req, res) => { 30 | // this will get compressed: 31 | res.end('hello world!'.repeat(1000)) 32 | }) 33 | .listen(3000) 34 | ``` 35 | 36 | Otherwise, just pass `req, res` primitives to it: 37 | 38 | ```js 39 | const compression = require('http-compression')({ /* see options below */ }) 40 | const { createServer } = require('http') 41 | 42 | const server = createServer((req, res) => { 43 | compression(req, res) 44 | res.end('hello world!'.repeat(1000)) 45 | }) 46 | 47 | server.listen(3000, () => { 48 | console.log('> Listening at http://localhost:3000') 49 | }) 50 | ``` 51 | 52 | ## API 53 | 54 | The `compression(options)` function returns an Express style middleware of the form `(req, res, next)`. 55 | 56 | ### Options 57 | 58 | #### threshold 59 | 60 | Type: `Number`
61 | Default: `1024` 62 | 63 | Responses below this threshold (in bytes) are not compressed. The default value of `1024` is recommended, and avoids sharply diminishing compression returns. 64 | 65 | #### level 66 | 67 | Type: `object`
68 | Default: `{ brotli: 1, gzip: 1 }` 69 | 70 | The compression effort/level/quality setting, used by both Gzip and Brotli. The scale range is: 71 | 72 | - brotli: from 0 to 11. 73 | - gzip: from -1 to 9. 74 | 75 | The library uses uses the most efficiency level by default determined by a [benchmark](bencharmk/index.mjs). 76 | 77 | #### brotli 78 | 79 | Type: `boolean`
80 | Default: `true` 81 | 82 | Enables response compression using Brotli for requests that support it. as determined by the `Accept-Encoding` request header. 83 | 84 | #### gzip 85 | 86 | Type: `boolean`
87 | Default: `true` 88 | 89 | Enables response compression using Gzip for requests that support it, as determined by the `Accept-Encoding` request header. 90 | 91 | #### mimes 92 | 93 | Type: `RegExp`
94 | Default: `/text|javascript|\/json|xml/i` 95 | 96 | The `Content-Type` response header is evaluated against this Regular Expression to determine if it is a MIME type that should be compressed. 97 | Remember that compression is generally only effective on textual content. 98 | 99 | ## License 100 | 101 | Thanks to [developit](https://github.com/developit) for written the original code implementation for [polka#148](https://github.com/lukeed/polka/pull/148). 102 | 103 | **http-compression** © [Kiko Beats](https://kikobeats.com), released under the [MIT](https://github.com/Kikobeats/http-compression/blob/master/LICENSE.md) License.
104 | Authored and maintained by [Kiko Beats](https://kikobeats.com) with help from [contributors](https://github.com/Kikobeats/http-compression/contributors). 105 | 106 | > [kikobeats.com](https://kikobeats.com) · GitHub [Kiko Beats](https://github.com/Kikobeats) · X [@Kikobeats](https://x.com/Kikobeats) 107 | -------------------------------------------------------------------------------- /benchmark/index.mjs: -------------------------------------------------------------------------------- 1 | import createTimeSpan from '@kikobeats/time-span' 2 | import zlib from 'zlib' 3 | 4 | const timeSpan = createTimeSpan() 5 | 6 | const url = process.argv[2] 7 | 8 | const response = await fetch(process.argv[2], { 9 | headers: { 'accept-encoding': 'identity' } 10 | }) 11 | 12 | const buffer = Buffer.from(await response.arrayBuffer()) 13 | 14 | console.log() 15 | console.log(`benchmarking ${url} payload – ${buffer.length}`) 16 | console.log() 17 | 18 | const percent = value => `${Number(value).toFixed(2)}%` 19 | 20 | const ratio = (compressed, buffer) => 100 - (compressed.length / buffer.length) * 100 21 | 22 | const efficiency = (compressed, buffer, time, timeWeight = 0.7) => { 23 | const compRatio = ratio(compressed, buffer) 24 | // Adjust the timeWeight to prioritize time more or less 25 | return compRatio * (1 - timeWeight) + (1 / time) * timeWeight * 100 26 | } 27 | 28 | const mostEfficient = collection => 29 | collection.reduce((bestIndex, current, index, array) => { 30 | const best = array[bestIndex] 31 | if (current.efficiency > best.efficiency) return index 32 | return bestIndex 33 | }, 0) 34 | 35 | const run = compress => { 36 | const duration = timeSpan() 37 | const compressed = compress() 38 | const end = duration() 39 | return { 40 | ratio: ratio(compressed, buffer), 41 | efficiency: efficiency(compressed, buffer, end) 42 | } 43 | } 44 | 45 | const bench = (compress, iterations = 100) => { 46 | const results = [] 47 | 48 | for (let i = 0; i < iterations; i++) { 49 | results.push(run(compress)) 50 | } 51 | 52 | const ratio = results.reduce((acc, { ratio }) => acc + ratio, 0) / results.length 53 | const efficiency = results.reduce((acc, { efficiency }) => acc + efficiency, 0) / results.length 54 | return { ratio, efficiency } 55 | } 56 | 57 | function brotli () { 58 | const results = [] 59 | 60 | for (let level = zlib.constants.BROTLI_MIN_QUALITY - 1; level <= zlib.constants.BROTLI_MAX_QUALITY; level++) { 61 | const { ratio, efficiency } = bench(() => 62 | zlib.brotliCompressSync(buffer, { 63 | params: { [zlib.constants.BROTLI_PARAM_QUALITY]: level } 64 | }) 65 | ) 66 | 67 | const annotation = (() => { 68 | const note = [] 69 | if (level === zlib.constants.BROTLI_DEFAULT_QUALITY) { 70 | note.push('(BROTLI_DEFAULT_QUALITY)') 71 | } 72 | if (level === zlib.constants.BROTLI_MIN_QUALITY) { 73 | note.push('(BROTLI_MIN_QUALITY)') 74 | } 75 | if (level === zlib.constants.BROTLI_MAX_QUALITY) { 76 | note.push('(BROTLI_MAX_QUALITY)') 77 | } 78 | return note.join(' ') 79 | })() 80 | 81 | results.push({ 82 | level, 83 | ratio, 84 | annotation, 85 | efficiency, 86 | toString: () => `brotli level=${level} ratio=${percent(ratio)} efficiency=${percent(efficiency)}\t${annotation}` 87 | }) 88 | } 89 | 90 | results.forEach(item => console.log(item.toString())) 91 | console.log(`\npreferred brotli level: ${results[mostEfficient(results)].toString()}\n`) 92 | } 93 | 94 | function gzip () { 95 | const results = [] 96 | 97 | for (let level = zlib.constants.Z_MIN_LEVEL; level <= zlib.constants.Z_MAX_LEVEL; level++) { 98 | // level 0 is no compression 99 | if (level === 0) continue 100 | const { ratio, efficiency } = bench(() => zlib.gzipSync(buffer, { level })) 101 | 102 | const annotation = (() => { 103 | const note = [] 104 | if (level === zlib.constants.Z_BEST_COMPRESSION) note.push('(Z_BEST_COMPRESSION)') 105 | if (level === zlib.constants.Z_DEFAULT_COMPRESSION) note.push('(Z_DEFAULT_COMPRESSION)') 106 | if (level === zlib.constants.Z_BEST_SPEED) note.push('(Z_BEST_SPEED)') 107 | if (level === zlib.constants.Z_NO_COMPRESSION) note.push('(Z_NO_COMPRESSION)') 108 | return note.join(' ') 109 | })() 110 | 111 | results.push({ 112 | level, 113 | ratio, 114 | annotation, 115 | efficiency, 116 | toString: () => `gzip level=${level} ratio=${percent(ratio)} efficiency=${percent(efficiency)}\t${annotation}` 117 | }) 118 | } 119 | 120 | results.forEach(item => console.log(item.toString())) 121 | console.log(`\npreferred gzip level: ${results[mostEfficient(results)].toString()}\n`) 122 | } 123 | 124 | function deflate () { 125 | const results = [] 126 | 127 | for (let level = zlib.constants.Z_MIN_LEVEL; level <= zlib.constants.Z_MAX_LEVEL; level++) { 128 | // level 0 is no compression 129 | if (level === 0) continue 130 | const { ratio, efficiency } = bench(() => zlib.deflateSync(buffer, { level })) 131 | results.push({ 132 | level, 133 | ratio, 134 | efficiency, 135 | toString: () => `deflate level=${level} ratio=${percent(ratio)} efficiency=${percent(efficiency)}\t` 136 | }) 137 | } 138 | 139 | results.forEach(item => console.log(item.toString())) 140 | console.log(`\npreferred deflate level: ${results[mostEfficient(results)].toString()}\n`) 141 | } 142 | 143 | brotli() 144 | gzip() 145 | deflate() 146 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-compression", 3 | "description": "Adding gzip/brotli for your HTTP server", 4 | "homepage": "https://github.com/Kikobeats/http-compression", 5 | "version": "1.1.1", 6 | "main": "src/index.js", 7 | "author": { 8 | "email": "josefrancisco.verdu@gmail.com", 9 | "name": "Kiko Beats", 10 | "url": "https://kikobeats.com" 11 | }, 12 | "contributors": [ 13 | { 14 | "name": "chenjiahan", 15 | "email": "chenjiahan.jait@bytedance.com" 16 | } 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/Kikobeats/http-compression.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/Kikobeats/http-compression/issues" 24 | }, 25 | "keywords": [ 26 | "br", 27 | "brotli", 28 | "compress", 29 | "compression", 30 | "deflate", 31 | "gzip", 32 | "http", 33 | "server" 34 | ], 35 | "devDependencies": { 36 | "@commitlint/cli": "latest", 37 | "@commitlint/config-conventional": "latest", 38 | "@kikobeats/time-span": "latest", 39 | "@ksmithut/prettier-standard": "latest", 40 | "async-listen": "latest", 41 | "ava": "latest", 42 | "c8": "latest", 43 | "ci-publish": "latest", 44 | "finepack": "latest", 45 | "git-authors-cli": "latest", 46 | "github-generate-release": "latest", 47 | "nano-staged": "latest", 48 | "simple-get": "latest", 49 | "simple-git-hooks": "latest", 50 | "standard": "latest", 51 | "standard-markdown": "latest", 52 | "standard-version": "latest" 53 | }, 54 | "engines": { 55 | "node": ">= 18" 56 | }, 57 | "files": [ 58 | "src" 59 | ], 60 | "scripts": { 61 | "clean": "rm -rf node_modules", 62 | "contributors": "(npx git-authors-cli && npx finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true", 63 | "coverage": "c8 report --reporter=text-lcov > coverage/lcov.info", 64 | "lint": "standard-markdown README.md && standard", 65 | "postrelease": "npm run release:tags && npm run release:github && (ci-publish || npm publish --access=public)", 66 | "prerelease": "npm run contributors", 67 | "pretest": "npm run lint", 68 | "release": "standard-version -a", 69 | "release:github": "github-generate-release", 70 | "release:tags": "git push --follow-tags origin HEAD:master", 71 | "test": "c8 ava" 72 | }, 73 | "license": "MIT", 74 | "ava": { 75 | "files": [ 76 | "test/**/*", 77 | "!test/util/**/*" 78 | ] 79 | }, 80 | "commitlint": { 81 | "extends": [ 82 | "@commitlint/config-conventional" 83 | ], 84 | "rules": { 85 | "body-max-line-length": [ 86 | 0 87 | ] 88 | } 89 | }, 90 | "nano-staged": { 91 | "*.js": [ 92 | "prettier-standard", 93 | "standard --fix" 94 | ], 95 | "*.md": [ 96 | "standard-markdown" 97 | ], 98 | "package.json": [ 99 | "finepack" 100 | ] 101 | }, 102 | "simple-git-hooks": { 103 | "commit-msg": "npx commitlint --edit", 104 | "pre-commit": "npx nano-staged" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const zlib = require('zlib') 4 | 5 | const MIMES = /text|javascript|\/json|xml/i 6 | 7 | const noop = () => {} 8 | 9 | const getChunkSize = (chunk, enc) => (chunk ? Buffer.byteLength(chunk, enc) : 0) 10 | 11 | /** 12 | * @param {object} [options] 13 | * @param {number} [options.threshold = 1024] Don't compress responses below this size (in bytes) 14 | * @param {object} [options.level = { brotli: 1, gzip: 7 }] - Compression effort levels for Brotli and Gzip. 15 | * @param {boolean} [options.brotli = true] Generate and serve Brotli-compressed responses 16 | * @param {boolean} [options.gzip = true] Generate and serve Gzip-compressed responses 17 | * @param {RegExp} [options.mimes] Regular expression of response MIME types to compress (default: text|javascript|json|xml) 18 | * @returns {(req: Pick, res: import('http').ServerResponse, next?:Function) => void} 19 | * @return Express style middleware 20 | */ 21 | module.exports = ({ 22 | threshold = 1024, 23 | level = { brotli: 0, gzip: 1 }, 24 | brotli = true, 25 | gzip = true, 26 | mimes = MIMES 27 | } = {}) => { 28 | const brotliOpts = (typeof brotli === 'object' && brotli) || {} 29 | const gzipOpts = (typeof gzip === 'object' && gzip) || {} 30 | 31 | if (brotli && !zlib.createBrotliCompress) brotli = false 32 | 33 | return (req, res, next = noop) => { 34 | const accept = req.headers['accept-encoding'] 35 | const encoding = 36 | accept && 37 | ((brotli && accept.match(/\bbr\b/)) || 38 | (gzip && accept.match(/\bgzip\b/)) || 39 | [])[0] 40 | 41 | if (req.method === 'HEAD' || !encoding) return next() 42 | 43 | let compress 44 | let pendingStatus 45 | let pendingListeners = [] 46 | let started = false 47 | let size = 0 48 | 49 | function start () { 50 | started = true 51 | size = res.getHeader('Content-Length') | 0 || size 52 | const compressible = mimes.test( 53 | String(res.getHeader('Content-Type') || 'text/plain') 54 | ) 55 | const cleartext = !res.getHeader('Content-Encoding') 56 | const listeners = pendingListeners || [] 57 | if (compressible && cleartext && size >= threshold) { 58 | res.setHeader('Content-Encoding', encoding) 59 | res.removeHeader('Content-Length') 60 | if (encoding === 'br') { 61 | compress = zlib.createBrotliCompress({ 62 | params: Object.assign({ 63 | [zlib.constants.BROTLI_PARAM_QUALITY]: level.brotli, 64 | [zlib.constants.BROTLI_PARAM_SIZE_HINT]: size 65 | }, brotliOpts) 66 | }) 67 | } else { 68 | compress = zlib.createGzip(Object.assign({ level: level.gzip }, gzipOpts)) 69 | } 70 | // backpressure 71 | compress.on( 72 | 'data', 73 | chunk => write.call(res, chunk) === false && compress.pause() 74 | ) 75 | on.call(res, 'drain', () => compress.resume()) 76 | compress.on('end', () => end.call(res)) 77 | listeners.forEach(p => compress.on.apply(compress, p)) 78 | } else { 79 | pendingListeners = null 80 | listeners.forEach(p => on.apply(res, p)) 81 | } 82 | 83 | writeHead.call(res, pendingStatus || res.statusCode) 84 | } 85 | 86 | const { end, write, on, writeHead } = res 87 | 88 | res.writeHead = function (status, reason, headers) { 89 | if (typeof reason !== 'string') [headers, reason] = [reason, headers] 90 | if (headers) for (const i in headers) res.setHeader(i, headers[i]) 91 | pendingStatus = status 92 | return this 93 | } 94 | 95 | res.write = function (chunk, enc) { 96 | size += getChunkSize(chunk, enc) 97 | if (!started) start() 98 | if (!compress) return write.apply(this, arguments) 99 | return compress.write.apply(compress, arguments) 100 | } 101 | 102 | res.end = function (chunk, enc) { 103 | if (arguments.length > 0 && typeof chunk !== 'function') { 104 | size += getChunkSize(chunk, enc) 105 | } 106 | if (!started) start() 107 | if (!compress) return end.apply(this, arguments) 108 | return compress.end.apply(compress, arguments) 109 | } 110 | 111 | res.on = function (type, listener) { 112 | if (!pendingListeners || type !== 'drain') on.call(this, type, listener) 113 | else if (compress) compress.on(type, listener) 114 | else pendingListeners.push([type, listener]) 115 | return this 116 | } 117 | 118 | next() 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /test/unit/brotli.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const zlib = require('zlib') 4 | 5 | const { toAscii, prepare } = require('../util') 6 | const compression = require('../../src') 7 | 8 | const test = 'createBrotliCompress' in zlib ? require('ava') : require('ava').skip 9 | 10 | test('compresses content with brotli when supported', async t => { 11 | const { req, res } = prepare('GET', 'br') 12 | compression({ threshold: 0, level: { brotli: 11 } })(req, res) 13 | res.writeHead(200, { 'content-type': 'text/plain' }) 14 | res.end('hello world') 15 | 16 | const body = await res.getResponseData() 17 | 18 | t.is(res.getHeader('content-encoding'), 'br') 19 | t.deepEqual(toAscii(body), toAscii('\u000b\u0005\u0000hello world\u0003')) 20 | }) 21 | 22 | test('gives brotli precedence over gzip', t => { 23 | const { req, res } = prepare('GET', 'br') 24 | compression({ threshold: 0 })(req, res) 25 | res.writeHead(200, { 'content-type': 'text/plain' }) 26 | res.end('hello world'.repeat(20)) 27 | 28 | t.is(res.getHeader('content-encoding'), 'br') 29 | }) 30 | -------------------------------------------------------------------------------- /test/unit/gzip.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('ava') 4 | 5 | const { prepare } = require('../util') 6 | const compression = require('../../src') 7 | 8 | test('compress body when over threshold', t => { 9 | const { req, res } = prepare('GET', 'gzip') 10 | 11 | compression()(req, res) 12 | res.writeHead(200, { 'content-type': 'text/plain' }) 13 | res.write('hello world'.repeat(1000)) 14 | res.end() 15 | 16 | t.is(res.getHeader('content-encoding'), 'gzip') 17 | }) 18 | 19 | test('compresses content with no content-type', t => { 20 | const { req, res } = prepare('GET', 'gzip') 21 | compression({ threshold: 0 })(req, res) 22 | res.end('hello world') 23 | 24 | t.is(res.getHeader('content-encoding'), 'gzip') 25 | }) 26 | 27 | test('ignores content with unmatched content-type', async t => { 28 | const { req, res } = prepare('GET', 'gzip') 29 | compression({ threshold: 0 })(req, res) 30 | res.writeHead(200, { 'content-type': 'image/jpeg' }) 31 | const content = 'hello world' 32 | res.end(content) 33 | 34 | t.is(res.hasHeader('content-encoding'), false) 35 | t.is(await res.getResponseText(), content) 36 | }) 37 | 38 | test('preserves plaintext below threshold', async t => { 39 | const { req, res } = prepare('GET', 'gzip') 40 | compression()(req, res) 41 | res.writeHead(200, { 'content-type': 'text/plain' }) 42 | const content = 'hello world'.repeat(20) 43 | res.end(content) 44 | 45 | t.is(res.hasHeader('content-encoding'), false) 46 | t.is(await res.getResponseText(), content) 47 | }) 48 | -------------------------------------------------------------------------------- /test/unit/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('ava') 4 | 5 | const { prepare, get, runServer } = require('../util') 6 | const compression = require('../../src') 7 | 8 | test('export a function', t => { 9 | t.is(typeof compression, 'function') 10 | }) 11 | 12 | test('installs as middleware', t => { 13 | const { req, res } = prepare('GET', 'gzip') 14 | const middleware = compression() 15 | 16 | let calledNext = false 17 | middleware(req, res, () => { 18 | calledNext = true 19 | }) 20 | 21 | t.true(calledNext) 22 | }) 23 | 24 | test('allow server to work if not compressing', async t => { 25 | const handler = (req, res) => { 26 | res.setHeader('content-type', 'text/plain') 27 | res.end('OK') 28 | } 29 | 30 | const url = await runServer(t, (req, res) => 31 | compression({ level: 1, threshold: 4 })(req, res, () => handler(req, res)) 32 | ) 33 | 34 | const { res, data } = await get(url, { 35 | headers: { 36 | 'accept-encoding': 'gzip' 37 | } 38 | }) 39 | 40 | t.is(res.statusCode, 200) 41 | t.is(data.toString(), 'OK') 42 | 43 | t.is(res.headers['content-type'], 'text/plain') 44 | t.is(res.headers['content-encoding'], undefined) 45 | t.is(res.headers['transfer-encoding'], 'chunked') 46 | t.is(res.headers['content-length'], undefined) 47 | }) 48 | 49 | test('respect `accept-encoding`', t => { 50 | { 51 | const { req, res } = prepare('GET', 'gzip;q=0.5, br;q=1.0') 52 | compression({ threshold: 0 })(req, res) 53 | 54 | res.writeHead(200, { 'content-type': 'text/plain' }) 55 | res.end('hello world'.repeat(20)) 56 | 57 | t.is(res.getHeader('content-encoding'), 'br') 58 | } 59 | 60 | { 61 | const { req, res } = prepare('GET', null) 62 | compression({ threshold: 0 })(req, res) 63 | 64 | res.writeHead(200, { 'content-type': 'text/plain' }) 65 | res.end('hello world'.repeat(20)) 66 | 67 | t.is(res.getHeader('content-encoding'), undefined) 68 | } 69 | }) 70 | 71 | test('respect `res.statusCode`', async t => { 72 | const handler = (req, res) => { 73 | res.statusCode = 201 74 | res.end('hello world') 75 | } 76 | 77 | const url = await runServer(t, (req, res) => 78 | compression({ threshold: 0 })(req, res, () => handler(req, res)) 79 | ) 80 | 81 | const { res, data } = await get(url, { 82 | headers: { 83 | 'accept-encoding': 'br' 84 | } 85 | }) 86 | 87 | t.is(res.headers['content-encoding'], 'br') 88 | t.is(res.statusCode, 201) 89 | t.is(data.toString(), 'hello world') 90 | }) 91 | 92 | test('respect `res.writeHead`', async t => { 93 | const handler = (req, res) => { 94 | res.writeHead(201) 95 | res.end('hello world') 96 | } 97 | 98 | const url = await runServer(t, (req, res) => 99 | compression({ threshold: 0 })(req, res, () => handler(req, res)) 100 | ) 101 | 102 | const { res, data } = await get(url, { 103 | headers: { 104 | 'accept-encoding': 'br' 105 | } 106 | }) 107 | 108 | t.is(res.headers['content-encoding'], 'br') 109 | t.is(res.statusCode, 201) 110 | t.is(data.toString(), 'hello world') 111 | }) 112 | -------------------------------------------------------------------------------- /test/unit/level.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { join } = require('path') 4 | const zlib = require('zlib') 5 | const test = require('ava') 6 | const fs = require('fs') 7 | 8 | const { prepare, toAscii } = require('../util') 9 | const compression = require('../../src') 10 | 11 | const contentPath = join(__dirname, '../../package.json') 12 | const content = fs.readFileSync(contentPath) 13 | 14 | test('gzip level 1 by default', async t => { 15 | const compressed = zlib.gzipSync(content, { level: 1 }) 16 | 17 | const { req, res } = prepare('GET', 'gzip') 18 | compression({ threshold: 0 })(req, res) 19 | 20 | res.writeHead(200, { 'content-type': 'text/plain' }) 21 | fs.createReadStream(contentPath).pipe(res) 22 | 23 | const body = await res.getResponseData() 24 | 25 | t.is(res.getHeader('content-encoding'), 'gzip') 26 | t.deepEqual(toAscii(body), toAscii(compressed)) 27 | }) 28 | 29 | test('brotli level 0 by default', async t => { 30 | const compressed = zlib.brotliCompressSync(content, { 31 | params: { 32 | [zlib.constants.BROTLI_PARAM_QUALITY]: 0 33 | } 34 | }) 35 | 36 | const { req, res } = prepare('GET', 'br') 37 | compression({ threshold: 0 })(req, res) 38 | 39 | res.writeHead(200, { 'content-type': 'text/plain' }) 40 | fs.createReadStream(contentPath).pipe(res) 41 | 42 | const body = await res.getResponseData() 43 | 44 | t.is(res.getHeader('content-encoding'), 'br') 45 | t.deepEqual(toAscii(body), toAscii(compressed)) 46 | }) 47 | -------------------------------------------------------------------------------- /test/util/index.js: -------------------------------------------------------------------------------- 1 | const { default: listen } = require('async-listen') 2 | const { ServerResponse } = require('http') 3 | const { createServer } = require('http') 4 | const simpleGet = require('simple-get') 5 | 6 | const closeServer = server => 7 | require('util').promisify(server.close.bind(server))() 8 | 9 | const runServer = async (t, handler) => { 10 | const server = createServer(handler) 11 | const url = await listen(server) 12 | t.teardown(() => closeServer(server)) 13 | return url 14 | } 15 | 16 | // IncomingMessage 17 | class Request { 18 | constructor (method = 'GET', headers = {}) { 19 | this.method = method.toUpperCase() 20 | this.headers = {} 21 | for (const i in headers) this.headers[i.toLowerCase()] = headers[i] 22 | } 23 | } 24 | 25 | const ENCODING = { 26 | gzip: 'gzip, deflate', 27 | br: 'br, gzip, deflate' 28 | } 29 | 30 | class Response extends ServerResponse { 31 | constructor (req) { 32 | super(req) 33 | this._chunks = [] 34 | this.done = new Promise(resolve => (this._done = resolve)) 35 | } 36 | 37 | /** @param chunk @param [enc] @param [cb] */ 38 | write (chunk, enc, cb) { 39 | if (!Buffer.isBuffer(chunk)) chunk = Buffer.from(chunk, enc) 40 | this._chunks.push(chunk) 41 | if (cb) cb(null) 42 | return true 43 | } 44 | 45 | /** @param chunk @param [enc] @param [cb] */ 46 | end (chunk, enc, cb) { 47 | if (chunk) this.write(chunk, enc) 48 | if (cb) cb() 49 | this._done(Buffer.concat(this._chunks)) 50 | } 51 | 52 | getResponseData () { 53 | return this.done 54 | } 55 | 56 | async getResponseText () { 57 | return (await this.done).toString() 58 | } 59 | } 60 | 61 | const prepare = (method, encoding) => { 62 | const req = new Request(method, { 'accept-encoding': ENCODING[encoding] || encoding }) 63 | const res = new Response(req) 64 | return { req, res } 65 | } 66 | 67 | const toAscii = input => 68 | JSON.stringify(Buffer.from(input).toString('ascii')).replace(/(^"|"$)/g, '') 69 | 70 | const get = (url, opts) => 71 | new Promise((resolve, reject) => 72 | simpleGet.concat({ url: url.toString(), ...opts }, (err, res, data) => 73 | err ? reject(err) : resolve({ res, data }) 74 | ) 75 | ) 76 | 77 | module.exports = { prepare, toAscii, runServer, get } 78 | --------------------------------------------------------------------------------