Skip to content

Commit

Permalink
Add @polka/compression package (#148)
Browse files Browse the repository at this point in the history
* Add @polka/compression package

* skip brotli tests on unsupported node versions

* allow Node>=6

* add stream piping test

* kick CI

* debug: drop action cache

* debug: reattach cache step

* fix: always call `writeHead` step

- see preactjs/wmr#648

* chore: backport fixes;

- now aligns w/ current wmr (and vite) impls

* feat: add dual esm/cjs typescript definitions

* chore: test types

---------

Co-authored-by: Luke Edwards <luke.edwards05@gmail.com>
  • Loading branch information
developit and lukeed authored Mar 7, 2024
1 parent 5ff616a commit a2105e0
Show file tree
Hide file tree
Showing 7 changed files with 504 additions and 0 deletions.
37 changes: 37 additions & 0 deletions packages/compression/index.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { IncomingMessage, ServerResponse } from 'node:http';

export type Options = {
/**
* Don't compress responses below this size (in bytes).
* @default 1024
*/
threshold?: number;
/**
* Gzip/Brotli compression effort (1-11, or -1 for default)
* @default -1
*/
level?: number;
/**
* Generate and serve Brotli-compressed responses.
* @default false
*/
brotli?: boolean;
/**
* Generate and serve Gzip-compressed responses.
* @default true
*/
gzip?: boolean;
/**
* Regular expression of response MIME types to compress.
* @default /text|javascript|\/json|xml/i
*/
mimes?: RegExp;
};

export type Middleware = (
request: Pick<IncomingMessage, 'method' | 'headers'>,
response: ServerResponse,
next?: (error?: Error | string) => any,
) => void;

export default function (options?: Options): Middleware;
41 changes: 41 additions & 0 deletions packages/compression/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { IncomingMessage, ServerResponse } from 'node:http';

declare namespace compression {
export type Options = {
/**
* Don't compress responses below this size (in bytes).
* @default 1024
*/
threshold?: number;
/**
* Gzip/Brotli compression effort (1-11, or -1 for default)
* @default -1
*/
level?: number;
/**
* Generate and serve Brotli-compressed responses.
* @default false
*/
brotli?: boolean;
/**
* Generate and serve Gzip-compressed responses.
* @default true
*/
gzip?: boolean;
/**
* Regular expression of response MIME types to compress.
* @default /text|javascript|\/json|xml/i
*/
mimes?: RegExp;
};

export type Middleware = (
request: Pick<IncomingMessage, 'method' | 'headers'>,
response: ServerResponse,
next?: (error?: Error | string) => any,
) => void;
}

declare function compression(options?: compression.Options): compression.Middleware;

export = compression;
115 changes: 115 additions & 0 deletions packages/compression/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// NOTE: supports Node 6.x

import zlib from 'zlib';

const NOOP = () => {};
const MIMES = /text|javascript|\/json|xml/i;

/**
* @param {any} chunk
* @param {BufferEncoding} enc
* @returns {number}
*/
function getChunkSize(chunk, enc) {
return chunk ? Buffer.byteLength(chunk, enc) : 0;
}

/**
* @param {import('./index.d.mts').Options} [options]
* @returns {import('./index.d.mts').Middleware}
*/
export default function ({ 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;
/** @type {Array<[string, function]>?} */
let pendingListeners = [];
let pendingStatus = 0;
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');
const listeners = pendingListeners || [];

if (compressible && cleartext && size >= threshold) {
res.setHeader('Content-Encoding', encoding);
res.removeHeader('Content-Length');
if (encoding === 'br') {
compress = zlib.createBrotliCompress({
params: Object.assign({
[zlib.constants.BROTLI_PARAM_QUALITY]: level,
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: size,
}, brotliOpts)
});
} else {
compress = zlib.createGzip(
Object.assign({ level }, gzipOpts)
);
}
// backpressure
compress.on('data', chunk => write.call(res, chunk) || compress.pause());
on.call(res, 'drain', () => compress.resume());
compress.on('end', () => end.call(res));
listeners.forEach(p => compress.on.apply(compress, p));
} else {
pendingListeners = null;
listeners.forEach(p => on.apply(res, p));
}

writeHead.call(res, pendingStatus || res.statusCode);
}

const { end, write, on, writeHead } = res;

res.writeHead = function (status, reason, headers) {
if (typeof reason !== 'string') [headers, reason] = [reason, headers];
if (headers) for (let k in headers) res.setHeader(k, headers[k]);
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);
};

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();
};
}
37 changes: 37 additions & 0 deletions packages/compression/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"version": "1.0.0-next.11",
"name": "@polka/compression",
"repository": "lukeed/polka",
"description": "Fast gzip+brotli compression middleware for polka & express with zero dependencies.",
"homepage": "https://github.com/lukeed/polka/tree/next/packages/compression",
"module": "build.mjs",
"types": "index.d.ts",
"main": "build.js",
"license": "MIT",
"exports": {
".": {
"import": {
"types": "./index.d.mts",
"default": "./build.mjs"
},
"require": {
"types": "./index.d.ts",
"default": "./build.js"
}
},
"./package.json": "./package.json"
},
"files": [
"build.*",
"index.d.*"
],
"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
Loading

0 comments on commit a2105e0

Please sign in to comment.