UNPKG

@nitedani/shrink-ray-current

Version:

Node.js compression middleware with brotli and zopfli support

479 lines (390 loc) 12.5 kB
'use strict'; /*! * compression * Copyright(c) 2019 CodeIter (https://github.com/CodeIter) * Copyright(c) 2017 Arturas Molcanovas * Copyright(c) 2010 Sencha Inc. * Copyright(c) 2011 TJ Holowaychuk * Copyright(c) 2014 Jonathan Ong * Copyright(c) 2014-2015 Douglas Christopher Wilson * MIT Licensed */ const accepts = require('accepts'); const bytes = require('bytes'); const compressible = require('compressible'); const debug = require('debug')('compression'); const Duplex = require('stream').Duplex; const LruCache = require('lru-cache') const multipipe = require('multipipe'); const onHeaders = require('on-headers'); const Readable = require('stream').Readable; const util = require('util'); const vary = require('vary'); const Writable = require('stream').Writable; const zlib = require("zlib") module.exports = compression; module.exports.filter = shouldCompress; const cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/; // according to https://blogs.akamai.com/2016/02/understanding-brotlis-potential.html , brotli:4 // is slightly faster than gzip with somewhat better compression; good default if we don't want to // worry about compression runtime being slower than gzip const BROTLI_DEFAULT_QUALITY = 4; function stubTrue() { return true; } /** * Compress response data with gzip / deflate. * * @param {Object} [options] * @return {Function} middleware * @public */ function compression(options) { const opts = options || {}; const filter = opts.filter || shouldCompress; let threshold = bytes.parse(opts.threshold); if (threshold === null) { threshold = 1024; } const brotliOpts = opts.brotli || {}; brotliOpts.quality = brotliOpts.quality || BROTLI_DEFAULT_QUALITY; const zlibOpts = opts.zlib || {}; const zlibOptNames = ['flush', 'chunkSize', 'windowBits', 'level', 'memLevel', 'strategy', 'dictionary']; zlibOptNames.forEach(function (option) { zlibOpts[option] = zlibOpts[option] || opts[option]; }); if (!opts.hasOwnProperty('cacheSize')) opts.cacheSize = '128mB'; const cache = opts.cacheSize ? createCache(bytes(opts.cacheSize.toString())) : null; const shouldCache = opts.cache || stubTrue; return function compression(req, res, next) { let ended = false; let length; let listeners = []; let stream; const _end = res.end; const _on = res.on; const _write = res.write; // flush res.flush = function flush() { if (stream) { stream.flush(); } }; // proxy res.write = function write(chunk, encoding) { if (ended) { return false; } if (!this._header) { this._implicitHeader(); } return stream ? stream.write(Buffer.from(chunk, encoding)) : _write.call(this, chunk, encoding); }; res.end = function end(chunk, encoding) { if (ended) { return false; } if (!this._header) { // estimate the length if (!this.getHeader('Content-Length')) { length = chunkLength(chunk, encoding); } this._implicitHeader(); } if (!stream) { return _end.call(this, chunk, encoding); } // mark ended ended = true; // write Buffer for Node.js 0.8 return chunk ? stream.end(Buffer.from(chunk, encoding)) : stream.end(); }; res.on = function on(type, listener) { if (!listeners || type !== 'drain') { return _on.call(this, type, listener); } if (stream) { return stream.on(type, listener); } // buffer listeners for future stream listeners.push([type, listener]); return this; }; function nocompress(msg) { debug('no compression: %s', msg); addListeners(res, _on, listeners); listeners = null; } onHeaders(res, function onResponseHeaders() { // determine if request is filtered if (!filter(req, res)) { nocompress('filtered'); return; } // determine if the entity should be transformed if (!shouldTransform(req, res)) { nocompress('no transform'); return; } // vary vary(res, 'Accept-Encoding'); // content-length below threshold if (Number(res.getHeader('Content-Length')) < threshold || length < threshold) { nocompress('size below threshold'); return; } const encoding = res.getHeader('Content-Encoding') || 'identity'; // already encoded if (encoding !== 'identity') { nocompress('already encoded'); return; } // head if (req.method === 'HEAD') { nocompress('HEAD request'); return; } const contentType = res.getHeader('Content-Type'); // compression method const accept = accepts(req); // send in each compression method separately to ignore client preference and // instead enforce server preference. also, server-sent events (mime type of // text/event-stream) require flush functionality, so skip brotli in that // case. // lastly, if brotli is unavailable or unsupported on this platform, // the object will be falsy. const method = (contentType !== 'text/event-stream' && accept.encoding('br')) || accept.encoding('gzip') || accept.encoding('deflate') || accept.encoding('identity'); // negotiation failed if (!method || method === 'identity') { nocompress('not acceptable'); return; } // do we have this coding/url/etag combo in the cache? const etag = res.getHeader('ETag') || null; const cacheable = cache && shouldCache(req, res) && etag && res.statusCode >= 200 && res.statusCode < 300; if (cacheable) { const buffer = cache.lookup(method, req.url, etag); if (buffer) { // the rest of the code expects a duplex stream, so // make a duplex stream that just ignores its input stream = new BufferDuplex(buffer); } } // if stream is not assigned, we got a cache miss and need to compress // the result if (!stream) { // compression stream debug('%s compression', method); switch (method) { case 'br': stream = zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: brotliOpts.quality } }); break; case 'gzip': stream = zlib.createGzip(zlibOpts); break; case 'deflate': stream = zlib.createDeflate(zlibOpts); break; } // if it is cacheable, let's keep hold of the compressed chunks and cache // them once the compression stream ends. if (cacheable) { const chunks = []; stream.on('data', function (chunk) { chunks.push(chunk); }); stream.on('end', function () { cache.add(method, req.url, etag, chunks); }); } } // add buffered listeners to stream addListeners(stream, stream.on, listeners); // header fields res.setHeader('Content-Encoding', method); res.removeHeader('Content-Length'); // compression stream.on('data', function onStreamData(chunk) { if (_write.call(res, chunk) === false) { stream.pause(); } }); stream.on('end', function onStreamEnd() { _end.call(res); }); _on.call(res, 'drain', function onResponseDrain() { stream.resume(); }); }); next(); }; } /** * Add bufferred listeners to stream * @private */ function addListeners(stream, on, listeners) { for (let i = 0; i < listeners.length; i++) { on.apply(stream, listeners[i]); } } /** * Get the length of a given chunk */ function chunkLength(chunk, encoding) { if (!chunk) { return 0; } return !Buffer.isBuffer(chunk) ? Buffer.byteLength(chunk, encoding) : chunk.length; } /** * Default filter function. * @private */ function shouldCompress(req, res) { const type = res.getHeader('Content-Type'); if (type === undefined || !compressible(type)) { debug('%s not compressible', type); return false; } return true; } /** * Determine if the entity should be transformed. * @private */ function shouldTransform(req, res) { const cacheControl = res.getHeader('Cache-Control'); // Don't compress for Cache-Control: no-transform // https://tools.ietf.org/html/rfc7234#section-5.2.2.4 return !cacheControl || !cacheControlNoTransformRegExp.test(cacheControl); } function createCache(size) { const index = {}; const lru = new LruCache.LRUCache({ maxSize: size, sizeCalculation: function (item, key) { return item.buffer.length + item.coding.length + 2 * (item.url.length + item.etag.length); }, dispose: function (item, key) { // remove this particular representation (by etag) delete index[item.coding][item.url][item.etag]; // if there are no more representations of the url left, remove the // entry for the url. if (Object.keys(index[item.coding][item.url]).length === 0) { delete index[item.coding][item.url]; } } }); return { add: function (coding, url, etag, buffer) { // check to see if another request already filled the cache; avoids // a lot of work if there's a thundering herd. if (index[coding] && index[coding][url] && index[coding][url][etag]) { return; } if (Array.isArray(buffer)) { buffer = Buffer.concat(buffer); } const item = { coding: coding, url: url, etag: etag, buffer: buffer }; const key = {}; index[coding] = index[coding] || {}; index[coding][url] = index[coding][url] || {}; index[coding][url][etag] = key; lru.set(key, item); // now asynchronously re-encode the entry at best quality const result = new BufferWritable(); new BufferReadable(buffer) .pipe(getBestQualityReencoder(coding)) .pipe(result) .on('finish', function () { const itemInCache = lru.peek(key); if (itemInCache) { itemInCache.buffer = result.toBuffer(); } }); }, lookup: function (coding, url, etag) { const key = index[coding]?.[url]?.[etag] if (key && lru.has(key)) { return lru.get(key).buffer; } return null; } }; } function BufferReadable(buffer, opt) { Readable.call(this, opt); this.buffer = buffer; } util.inherits(BufferReadable, Readable); BufferReadable.prototype._read = function (size) { if (!this.ended) { this.push(this.buffer); this.ended = true; } else { this.push(null); } }; function BufferWritable(opt) { Writable.call(this, opt); this.chunks = []; } util.inherits(BufferWritable, Writable); BufferWritable.prototype._write = function (chunk, encoding, callback) { this.chunks.push(chunk); callback(); }; BufferWritable.prototype.toBuffer = function () { return Buffer.concat(this.chunks); }; // this duplex just ignores its write side and reads out the buffer as // requested function BufferDuplex(buffer, opts) { Duplex.call(this, opts); this.buffer = buffer; } util.inherits(BufferDuplex, Duplex); BufferDuplex.prototype._read = function (size) { if (!this.cursor) this.cursor = 0; if (this.cursor >= this.buffer.length) { this.push(null); return; } const endIndex = Math.min(this.cursor + size, this.buffer.length); this.push(this.buffer.slice(this.cursor, endIndex)); this.cursor = endIndex; }; BufferDuplex.prototype._write = function (chunk, encoding, callback) { callback(); }; // get a decode --> encode transform stream that will re-encode the content at // the best quality available for that coding method. function getBestQualityReencoder(coding) { switch (coding) { case 'gzip': return multipipe(zlib.createGunzip(), zlib.createGzip({ level: zlib.constants.Z_MAX_LEVEL })); case 'deflate': return multipipe(zlib.createInflate(), zlib.createDeflate({ level: zlib.constants.Z_MAX_LEVEL })) case 'br': return multipipe(zlib.createBrotliDecompress(), zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY } })); } }