Skip to content
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 11 commits into from
Mar 7, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions packages/compression/index.js
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);

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@23bee83

Copy link

@benmccann benmccann Mar 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

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();
};
}
22 changes: 22 additions & 0 deletions packages/compression/package.json
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"
}
}
82 changes: 82 additions & 0 deletions packages/compression/readme.md
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
132 changes: 132 additions & 0 deletions packages/compression/test/index.js
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();
45 changes: 45 additions & 0 deletions packages/compression/test/util/index.js
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,'');