-
-
Notifications
You must be signed in to change notification settings - Fork 174
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add @polka/compression package #148
Merged
Merged
Changes from 7 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
40e15ab
Add @polka/compression package
developit 7499546
skip brotli tests on unsupported node versions
developit 0f6bcbd
allow Node>=6
developit ae5960c
add stream piping test
developit 072fc78
kick CI
developit f5e9a4f
debug: drop action cache
lukeed 425062a
debug: reattach cache step
lukeed 11e459c
fix: always call `writeHead` step
lukeed 5e00d70
chore: backport fixes;
lukeed d9e066a
feat: add dual esm/cjs typescript definitions
lukeed b4a6a69
chore: test types
lukeed File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import zlib from 'zlib'; | ||
|
||
/* global Buffer */ | ||
|
||
const MIMES = /text|javascript|\/json|xml/i; | ||
|
||
const noop = () => {}; | ||
|
||
const getChunkSize = (chunk, enc) => chunk ? Buffer.byteLength(chunk, enc) : 0; | ||
|
||
/** | ||
* @param {object} [options] | ||
* @param {number} [options.threshold = 1024] Don't compress responses below this size (in bytes) | ||
* @param {number} [options.level = -1] Gzip/Brotli compression effort (1-11, or -1 for default) | ||
* @param {boolean} [options.brotli = false] Generate and serve Brotli-compressed responses | ||
* @param {boolean} [options.gzip = true] Generate and serve Gzip-compressed responses | ||
* @param {RegExp} [options.mimes] Regular expression of response MIME types to compress (default: text|javascript|json|xml) | ||
* @returns {(req: Pick<import('http').IncomingMessage, 'method'|'headers'>, res: import('http').ServerResponse, next?:Function) => void} | ||
* @retur {import('polka').Middleware} | ||
*/ | ||
export default function compression({ threshold = 1024, level = -1, brotli = false, gzip = true, mimes = MIMES } = {}) { | ||
const brotliOpts = (typeof brotli === 'object' && brotli) || {}; | ||
const gzipOpts = (typeof gzip === 'object' && gzip) || {}; | ||
|
||
// disable Brotli on Node<12.7 where it is unsupported: | ||
if (!zlib.createBrotliCompress) brotli = false; | ||
|
||
return (req, res, next = noop) => { | ||
const accept = req.headers['accept-encoding'] + ''; | ||
const encoding = ((brotli && accept.match(/\bbr\b/)) || (gzip && accept.match(/\bgzip\b/)) || [])[0]; | ||
|
||
// skip if no response body or no supported encoding: | ||
if (req.method === 'HEAD' || !encoding) return next(); | ||
|
||
/** @type {zlib.Gzip | zlib.BrotliCompress} */ | ||
let compress; | ||
let pendingStatus; | ||
let started = false; | ||
let size = 0; | ||
|
||
function start() { | ||
started = true; | ||
// @ts-ignore | ||
size = res.getHeader('Content-Length') | 0 || size; | ||
const compressible = mimes.test(String(res.getHeader('Content-Type') || 'text/plain')); | ||
const cleartext = !res.getHeader('Content-Encoding'); | ||
if (compressible && cleartext && size >= threshold) { | ||
res.setHeader('Content-Encoding', encoding); | ||
res.removeHeader('Content-Length'); | ||
if (encoding === 'br') { | ||
const params = { | ||
[zlib.constants.BROTLI_PARAM_QUALITY]: level, | ||
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: size | ||
}; | ||
compress = zlib.createBrotliCompress({ params: Object.assign(params, brotliOpts) }); | ||
} else { | ||
compress = zlib.createGzip(Object.assign({ level }, gzipOpts)); | ||
} | ||
// backpressure | ||
compress.on('data', (...args) => write.apply(res, args) === false && compress.pause()); | ||
on.call(res, 'drain', () => compress.resume()); | ||
compress.on('end', (...args) => end.apply(res, args)); | ||
} | ||
|
||
const listeners = pendingListeners; | ||
pendingListeners = null; | ||
listeners.forEach(p => on.apply(res, p)); | ||
if (pendingStatus) writeHead.call(res, pendingStatus); | ||
} | ||
|
||
const { end, write, on, writeHead } = res; | ||
|
||
res.writeHead = function (status, reason, headers) { | ||
if (typeof reason !== 'string') [headers, reason] = [reason, headers]; | ||
if (headers) for (let i in headers) res.setHeader(i, headers[i]); | ||
pendingStatus = status; | ||
return this; | ||
}; | ||
|
||
res.write = function (chunk, enc) { | ||
size += getChunkSize(chunk, enc); | ||
if (!started) start(); | ||
if (!compress) return write.apply(this, arguments); | ||
return compress.write.apply(compress, arguments); | ||
}; | ||
|
||
res.end = function (chunk, enc) { | ||
if (arguments.length > 0 && typeof chunk !== 'function') { | ||
size += getChunkSize(chunk, enc); | ||
} | ||
if (!started) start(); | ||
if (!compress) return end.apply(this, arguments); | ||
return compress.end.apply(compress, arguments); | ||
}; | ||
|
||
let pendingListeners = []; | ||
res.on = function (type, listener) { | ||
if (!pendingListeners) on.call(this, type, listener); | ||
else if (compress) compress.on(type, listener); | ||
else pendingListeners.push([type, listener]); | ||
return this; | ||
}; | ||
|
||
next(); | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
{ | ||
"version": "1.0.0-next.11", | ||
"name": "@polka/compression", | ||
"repository": "lukeed/polka", | ||
"homepage": "https://github.com/lukeed/polka/tree/master/packages/compression", | ||
"description": "Fast gzip+brotli compression middleware for polka & express with zero dependencies.", | ||
"module": "build.mjs", | ||
"main": "build.js", | ||
"license": "MIT", | ||
"files": [ | ||
"build.*" | ||
], | ||
"authors": [ | ||
"Jason Miller (https://github.com/developit)" | ||
], | ||
"engines": { | ||
"node": ">=6" | ||
}, | ||
"publishConfig": { | ||
"access": "public" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
# @polka/compression [![npm](https://badgen.now.sh/npm/v/@polka/compression)](https://npmjs.org/package/@polka/compression) | ||
|
||
> An HTTP response compression middleware that supports native Gzip and Brotli. Works with [Polka][polka] and Express! | ||
|
||
|
||
## Install | ||
|
||
``` | ||
$ npm install --save @polka/compression | ||
``` | ||
|
||
|
||
## Usage | ||
|
||
```js | ||
const polka = require('polka'); | ||
const compression = require('@polka/compression'); | ||
|
||
polka() | ||
.use(compression({ /* see options below */ })) | ||
.use((req, res) => { | ||
// this will get compressed: | ||
res.end('hello world!'.repeat(1000)); | ||
}) | ||
.listen(); | ||
``` | ||
|
||
|
||
## API | ||
|
||
The `compression(options)` function returns a polka/express -style middleware of the form `(req, res, next)`. | ||
|
||
### Options | ||
|
||
* @param {number} [options.threshold = 1024] Don't compress responses below this size (in bytes) | ||
* @param {number} [options.level = -1] Gzip/Brotli compression effort (1-11, or -1 for default) | ||
* @param {boolean} [options.brotli = false] Generate and serve Brotli-compressed responses | ||
* @param {boolean} [options.gzip = true] Generate and serve Gzip-compressed responses | ||
* @param {RegExp} [options.mimes] Regular expression of response MIME types to compress (default: text|javascript|json|xml) | ||
|
||
#### threshold | ||
Type: `Number`<br> | ||
Default: `1024` | ||
|
||
Responses below this threshold (in bytes) are not compressed. The default value of `1024` is recommended, and avoids sharply diminishing compression returns. | ||
|
||
#### level | ||
Type: `Number`<br> | ||
Default: `-1` | ||
|
||
The compression effort/level/quality setting, used by both Gzip and Brotli. The scale ranges from 1 to 11, where lower values are faster and higher values produce smaller output. The default value of `-1` uses the default compression level as defined by Gzip (6) and Brotli (6). | ||
|
||
#### brotli | ||
Type: `boolean`<br> | ||
Default: `false` | ||
|
||
Enables response compression using Brotli for requests that support it. This is not enabled by default because Brotli incurs more performance overhead than Gzip. | ||
|
||
#### gzip | ||
Type: `boolean`<br> | ||
Default: `true` | ||
|
||
Enables response compression using Gzip for requests that support it, as determined by the `Accept-Encoding` request header. | ||
|
||
#### mimes | ||
Type: `RegExp`<br> | ||
Default: `/text|javascript|\/json|xml/i` | ||
|
||
The `Content-Type` response header is evaluated against this Regular Expression to determine if it is a MIME type that should be compressed. | ||
Remember that compression is generally only effective on textual content. | ||
|
||
|
||
## Support | ||
|
||
Any issues or questions can be sent to the [Polka][polka] repo, but please specify that you are using `@polka/compression`. | ||
|
||
|
||
## License | ||
|
||
MIT | ||
|
||
[polka]: https://github.com/lukeed/polka |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
import { join } from 'path'; | ||
import fs from 'fs'; | ||
import { suite } from 'uvu'; | ||
import * as assert from 'uvu/assert'; | ||
import { prep, toAscii } from './util/index'; | ||
import compression from '../index'; | ||
import * as zlib from 'zlib'; | ||
|
||
const GZIP = 'gzip, deflate'; | ||
const BR = 'br, gzip, deflate'; | ||
|
||
const instantiation = suite('instantiation'); | ||
|
||
instantiation('should export a function', () => { | ||
assert.type(compression, 'function'); | ||
}); | ||
|
||
instantiation('installs as middleware', () => { | ||
const { req, res } = prep('GET', GZIP); | ||
const middleware = compression(); | ||
|
||
let calledNext = false; | ||
middleware(req, res, () => { | ||
calledNext = true; | ||
}); | ||
assert.ok(calledNext); | ||
}); | ||
|
||
instantiation('works without next callback', () => { | ||
const { req, res } = prep('GET', GZIP); | ||
const middleware = compression(); | ||
assert.not.throws(() => middleware(req, res)); | ||
}); | ||
|
||
instantiation.run(); | ||
|
||
// --- | ||
|
||
const basics = suite('basics'); | ||
|
||
basics('compresses content over threshold', () => { | ||
const { req, res } = prep('GET', GZIP); | ||
compression()(req, res); | ||
res.writeHead(200, { 'content-type': 'text/plain' }) | ||
res.write('hello world'.repeat(1000)); | ||
res.end(); | ||
|
||
assert.is(res.getHeader('content-encoding'), 'gzip', 'above threshold triggers gzip'); | ||
}); | ||
|
||
basics('compresses content with no content-type', () => { | ||
const { req, res } = prep('GET', GZIP); | ||
compression({ threshold: 0 })(req, res); | ||
res.end('hello world'); | ||
|
||
assert.is(res.getHeader('content-encoding'), 'gzip', 'above threshold triggers gzip'); | ||
}); | ||
|
||
basics('ignores content with unmatched content-type', async () => { | ||
const { req, res } = prep('GET', GZIP); | ||
compression({ threshold: 0 })(req, res); | ||
res.writeHead(200, { 'content-type': 'image/jpeg' }); | ||
const content = 'hello world'; | ||
res.end(content); | ||
|
||
assert.is(res.hasHeader('content-encoding'), false, 'no content-encoding header should be set'); | ||
assert.is(await res.getResponseText(), content, 'content should be unmodified'); | ||
}); | ||
|
||
basics('preserves plaintext below threshold', async () => { | ||
const { req, res } = prep('GET', GZIP); | ||
compression()(req, res); | ||
res.writeHead(200, { 'content-type': 'text/plain' }); | ||
const content = 'hello world'.repeat(20); | ||
res.end(content); | ||
|
||
assert.is(res.hasHeader('content-encoding'), false, 'below threshold triggers gzip'); | ||
assert.is(await res.getResponseText(), content, 'content should be unmodified'); | ||
}); | ||
|
||
basics.run(); | ||
|
||
// --- | ||
|
||
const brotli = suite('brotli'); | ||
|
||
const brotliIfSupported = zlib.createBrotliCompress ? brotli : brotli.skip; | ||
|
||
brotliIfSupported('compresses content with brotli when supported', async () => { | ||
const { req, res } = prep('GET', 'br'); | ||
compression({ brotli: true, threshold: 0 })(req, res); | ||
res.writeHead(200, { 'content-type': 'text/plain' }) | ||
res.end('hello world'); | ||
|
||
const body = await res.getResponseData(); | ||
|
||
assert.is(res.getHeader('content-encoding'), 'br', 'uses brotli encoding'); | ||
assert.snapshot(toAscii(body), toAscii('\u000b\u0005\u0000hello world\u0003'), 'compressed content'); | ||
}); | ||
|
||
brotliIfSupported('gives brotli precedence over gzip', () => { | ||
const { req, res } = prep('GET', BR); | ||
compression({ brotli: true, threshold: 0 })(req, res); | ||
res.writeHead(200, { 'content-type': 'text/plain' }) | ||
res.end('hello world'.repeat(20)); | ||
|
||
assert.is(res.getHeader('content-encoding'), 'br', 'uses brotli encoding'); | ||
}); | ||
|
||
brotli.run(); | ||
|
||
// --- | ||
|
||
const streaming = suite('streaming'); | ||
|
||
streaming('allows piping streams', async () => { | ||
const pkg = join(__dirname, '../package.json'); | ||
const gzipped = zlib.gzipSync(fs.readFileSync(pkg)); | ||
|
||
const { req, res } = prep('GET', GZIP); | ||
compression({ threshold: 0 })(req, res); | ||
|
||
res.writeHead(200, { 'content-type': 'text/plain' }); | ||
fs.createReadStream(pkg).pipe(res, { end: true }); | ||
|
||
const body = await res.getResponseData(); | ||
|
||
assert.is(res.getHeader('content-encoding'), 'gzip', 'compresses with gzip'); | ||
assert.equal(toAscii(body), toAscii(gzipped), 'content is compressed'); | ||
}); | ||
|
||
streaming.run(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
import { ServerResponse } from 'http'; | ||
|
||
// IncomingMessage | ||
export class Request { | ||
constructor(method = 'GET', headers = {}) { | ||
this.method = method.toUpperCase(); | ||
this.headers = {}; | ||
for (let i in headers) this.headers[i.toLowerCase()] = headers[i]; | ||
} | ||
} | ||
|
||
export class Response extends ServerResponse { | ||
constructor(req) { | ||
super(req); | ||
this._chunks = []; | ||
this.done = new Promise((resolve) => this._done = resolve); | ||
} | ||
/** @param chunk @param [enc] @param [cb] */ | ||
write(chunk, enc, cb) { | ||
if (!Buffer.isBuffer(chunk)) chunk = Buffer.from(chunk, enc); | ||
this._chunks.push(chunk); | ||
if (cb) cb(null); | ||
return true; | ||
} | ||
/** @param chunk @param [enc] @param [cb] */ | ||
end(chunk, enc, cb) { | ||
if (chunk) this.write(chunk, enc); | ||
if (cb) cb(); | ||
this._done(Buffer.concat(this._chunks)); | ||
} | ||
getResponseData() { | ||
return this.done; | ||
} | ||
async getResponseText() { | ||
return (await this.done).toString(); | ||
} | ||
} | ||
|
||
export function prep(method, encoding) { | ||
let req = new Request(method, { 'Accept-Encoding': encoding }); | ||
let res = new Response(req); | ||
return { req, res }; | ||
} | ||
|
||
export const toAscii = thing => JSON.stringify(Buffer.from(thing).toString('ascii')).replace(/(^"|"$)/g,''); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see that @lukeed updated this line in
wmr
: preactjs/wmr@23bee83There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's also been updated in Vite's version: https://github.com/vitejs/vite/blob/eef9da13d0028161eacc0ea699988814f29a56e4/packages/vite/src/node/server/middlewares/compression.ts#L80
And the Kikobeats version: https://github.com/Kikobeats/http-compression