diff --git a/packages/wmr/package.json b/packages/wmr/package.json index ac2b811bd..7998ce872 100644 --- a/packages/wmr/package.json +++ b/packages/wmr/package.json @@ -63,6 +63,7 @@ "devcert": "^1.1.2", "errorstacks": "^2.3.2", "es-module-lexer": "^0.7.0", + "http-compression": "^1.0.4", "jest": "^26.1.0", "jest-puppeteer": "^5.0.4", "kolorist": "^1.5.0", diff --git a/packages/wmr/src/lib/polkompress.js b/packages/wmr/src/lib/polkompress.js deleted file mode 100644 index 16880082e..000000000 --- a/packages/wmr/src/lib/polkompress.js +++ /dev/null @@ -1,107 +0,0 @@ -import zlib from 'zlib'; - -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, 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; - /** @type {[string, function][]?} */ - let pendingListeners = []; - 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') { - 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', chunk => write.call(res, chunk) === false && 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 i in headers) res.setHeader(i, headers[i]); - pendingStatus = status; - return this; - }; - - res.write = function (chunk, enc, cb) { - 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, cb) { - 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 || type !== 'drain') on.call(this, type, listener); - else if (compress) compress.on(type, listener); - else pendingListeners.push([type, listener]); - return this; - }; - - next(); - }; -} diff --git a/packages/wmr/src/serve.js b/packages/wmr/src/serve.js index be0a514a8..f5bd45266 100644 --- a/packages/wmr/src/serve.js +++ b/packages/wmr/src/serve.js @@ -4,7 +4,7 @@ import { normalizeOptions } from './lib/normalize-options.js'; import { getServerAddresses } from './lib/net-utils.js'; import { createServer } from 'http'; import { createHttp2Server } from './lib/http2.js'; -import compression from './lib/polkompress.js'; +import compression from 'http-compression'; import sirv from 'sirv'; import { formatBootMessage } from './lib/output-utils.js'; @@ -59,7 +59,7 @@ export default async function serve(options = {}) { if (options.compress) { const threshold = options.compress === true ? 1024 : options.compress; - app.use(compression({ threshold })); + app.use(compression({ brotli: false, threshold })); } if (options.middleware && options.middleware.length) { diff --git a/packages/wmr/src/server.js b/packages/wmr/src/server.js index e0ee613d9..c8bbd5292 100644 --- a/packages/wmr/src/server.js +++ b/packages/wmr/src/server.js @@ -4,7 +4,7 @@ import { createServer } from 'http'; import { createHttp2Server } from './lib/http2.js'; import polka from 'polka'; import sirv from 'sirv'; -import compression from './lib/polkompress.js'; +import compression from 'http-compression'; import npmMiddleware from './lib/npm-middleware.js'; import WebSocketServer from './lib/websocket-server.js'; import * as kl from 'kolorist'; @@ -101,7 +101,7 @@ export default async function server({ cwd, root, overlayDir, middleware, http2, if (compress) { // @TODO: reconsider now that npm deps are compressed AOT const threshold = compress === true ? 1024 : compress; - app.use(compression({ threshold, level: 4 })); + app.use(compression({ brotli: false, threshold, level: 4 })); } // Custom middlewares should always come first, similar to plugins diff --git a/packages/wmr/test/lib/polkompress.test.js b/packages/wmr/test/lib/polkompress.test.js deleted file mode 100644 index 8de2ffaf0..000000000 --- a/packages/wmr/test/lib/polkompress.test.js +++ /dev/null @@ -1,127 +0,0 @@ -import { createServer } from 'http'; -import compression from '../../src/lib/polkompress.js'; -import { get } from '../test-helpers.js'; - -/** - * @param {string} address - * @param {string} [pathname] - */ -function send(address, pathname = '/') { - let ctx = /** @type {*} */ ({ address }); - return get(ctx, pathname); -} - -function setup(handler) { - let mware = compression({ level: 1, threshold: 4 }); - let server = createServer((req, res) => { - req.headers['accept-encoding'] = 'gzip'; - res.setHeader('content-type', 'text/plain'); - mware(req, res, () => handler(req, res)); - }); - return { - listen() { - return new Promise(res => { - server.listen(() => { - let info = server.address(); - let port = /** @type {import('net').AddressInfo} */ (info).port; - return res(`http://localhost:${port}`); - }); - }); - }, - close() { - server.close(); - } - }; -} - -describe('polkompress', () => { - it('should be a function', () => { - expect(typeof compression).toBe('function'); - }); - - it('should return a function', () => { - expect(typeof compression()).toBe('function'); - }); - - it('should allow server to work if not compressing', async () => { - const server = setup((r, res) => { - res.end('OK'); - }); - - try { - const address = await server.listen(); - const output = await send(address); - expect(output.status).toBe(200); - expect(output.body).toBe('OK'); - - const headers = output.res.headers; - expect(headers['content-type']).toBe('text/plain'); - expect(headers['content-encoding']).toBe(undefined); - expect(headers['transfer-encoding']).toBe('chunked'); - expect(headers['content-length']).toBe(undefined); - } finally { - server.close(); - } - }); - - it('should compress body when over threshold', async () => { - const server = setup((r, res) => { - res.end('HELLO WORLD'); - }); - - try { - const address = await server.listen(); - const output = await send(address); - expect(output.status).toBe(200); - expect(output.body).not.toBe('HELLO WORLD'); - - const headers = output.res.headers; - expect(headers['content-type']).toBe('text/plain'); - expect(headers['content-encoding']).toBe('gzip'); - expect(headers['transfer-encoding']).toBe('chunked'); - expect(headers['content-length']).toBe(undefined); - } finally { - server.close(); - } - }); - - it('should respect custom `statusCode` when set :: enabled', async () => { - const server = setup((r, res) => { - res.statusCode = 201; - res.end('HELLO WORLD'); - }); - - try { - const address = await server.listen(); - const output = await send(address); - expect(output.status).toBe(201); - expect(output.body).not.toBe('HELLO WORLD'); - - const headers = output.res.headers; - expect(headers['content-encoding']).toBe('gzip'); - expect(headers['transfer-encoding']).toBe('chunked'); - } finally { - server.close(); - } - }); - - it('should respect custom `statusCode` when set :: disabled', async () => { - const server = setup((r, res) => { - res.statusCode = 201; - res.end('OK'); - }); - - try { - const address = await server.listen(); - const output = await send(address); - expect(output.status).toBe(201); - expect(output.body).toBe('OK'); - - const headers = output.res.headers; - expect(headers['content-encoding']).toBe(undefined); - expect(headers['transfer-encoding']).toBe('chunked'); - } finally { - server.close(); - } - }); -}); diff --git a/yarn.lock b/yarn.lock index d69450745..d034ba624 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4335,6 +4335,11 @@ http-cache-semantics@^4.0.0: resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390" integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ== +http-compression@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/http-compression/-/http-compression-1.0.4.tgz#efa32f4751fa4deba010ba1a6a24e88b6b6a2434" + integrity sha512-LBOD+rkf1+y+jCeCzzGCpY/foA4nPz3NmX1VvjEDDYtFt1Prd1rQmGd4HQFFIcWM2PsXlVZvo/TvfAVAZP01eQ== + http-errors@1.7.2: version "1.7.2" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"