diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ce4a443..4b0c86f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,10 @@ jobs: - Node.js 15.x - Node.js 16.x - Node.js 17.x + - Node.js 18.x + - Node.js 19.x + - Node.js 20.x + include: - name: Node.js 0.8 @@ -75,11 +79,11 @@ jobs: - name: Node.js 8.x node-version: "8.16" - npm-i: mocha@7.2.0 + npm-i: mocha@7.2.0 nyc@14.1.1 supertest@6.1.6 - name: Node.js 9.x node-version: "9.11" - npm-i: mocha@7.2.0 + npm-i: mocha@7.2.0 nyc@14.1.1 supertest@6.1.6 - name: Node.js 10.x node-version: "10.16" @@ -107,6 +111,14 @@ jobs: - name: Node.js 17.x node-version: "17.9" + - name: Node.js 18.x + node-version: "18.19.1" + + - name: Node.js 19.x + node-version: "19.9.0" + + - name: Node.js 20.x + node-version: "20.11" steps: - uses: actions/checkout@v2 @@ -123,7 +135,12 @@ jobs: dirname "$(nvm which ${{ matrix.node-version }})" >> "$GITHUB_PATH" - name: Configure npm - run: npm config set shrinkwrap false + run: | + if [[ "$(cut -d. -f1 <<< "${{ matrix.node-version }}")" -le 17 ]]; then + npm config set shrinkwrap false + else + npm config set package-lock false + fi - name: Remove npm module(s) ${{ matrix.npm-rm }} run: npm rm --silent --save-dev ${{ matrix.npm-rm }} diff --git a/index.js b/index.js index 1d089427..65ab6ae6 100644 --- a/index.js +++ b/index.js @@ -22,6 +22,9 @@ var debug = require('debug')('compression') var onHeaders = require('on-headers') var vary = require('vary') var zlib = require('zlib') +var ServerResponse = require('http').ServerResponse + +var isOldRuntime = /^v0\.8\./.test(process.version) /** * Module exports. @@ -36,6 +39,10 @@ module.exports.filter = shouldCompress */ var cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/ +var hasUint8Array = (typeof Uint8Array === 'function') +function isUint8Array (arg) { + return hasUint8Array && arg && (arg instanceof Uint8Array || arg.toString() === '[object Uint8Array]') +} /** * Compress response data with gzip / deflate. @@ -56,6 +63,8 @@ function compression (options) { threshold = 1024 } + function noop () { } + return function compression (req, res, next) { var ended = false var length @@ -75,8 +84,37 @@ function compression (options) { // proxy - res.write = function write (chunk, encoding) { - if (ended) { + res.write = function write (chunk, encoding, callback) { + if (chunk === null) { + // throw ERR_STREAM_NULL_VALUES + return _write.call(this, chunk, encoding, callback) + } else if (typeof chunk === 'string' || typeof chunk.fill === 'function' || isUint8Array(chunk)) { + // noop + } else { + // throw ERR_INVALID_ARG_TYPE + return _write.call(this, chunk, encoding, callback) + } + + if (!callback && typeof encoding === 'function') { + callback = encoding + encoding = undefined + } + + if (typeof callback !== 'function') { + callback = noop + } + + if (res.destroyed || res.finished || ended) { + // HACK: node doesn't expose internal errors, + // we need to fake response to throw underlying errors type + var fakeRes = new ServerResponse({}) + fakeRes.on('error', function (err) { + res.emit('error', err) + }) + fakeRes.destroyed = res.destroyed + fakeRes.finished = res.finished || ended + // throw ERR_STREAM_DESTROYED or ERR_STREAM_WRITE_AFTER_END + _write.call(fakeRes, chunk, encoding, callback) return false } @@ -84,14 +122,37 @@ function compression (options) { this._implicitHeader() } + if (chunk) { + chunk = toBuffer(chunk, encoding) + if (isOldRuntime && stream) { + encoding = callback + } + } + return stream - ? stream.write(toBuffer(chunk, encoding)) - : _write.call(this, chunk, encoding) + ? stream.write(chunk, encoding, callback) + : _write.call(this, chunk, encoding, callback) } - res.end = function end (chunk, encoding) { - if (ended) { - return false + res.end = function end (chunk, encoding, callback) { + if (!callback) { + if (typeof chunk === 'function') { + callback = chunk + chunk = encoding = undefined + } else if (typeof encoding === 'function') { + callback = encoding + encoding = undefined + } + } + + if (typeof callback !== 'function') { + callback = noop + } + + if (this.destroyed || this.finished || ended) { + this.finished = ended + // throw ERR_STREAM_WRITE_AFTER_END or ERR_STREAM_ALREADY_FINISHED + return _end.call(this, chunk, encoding, callback) } if (!this._header) { @@ -104,16 +165,23 @@ function compression (options) { } if (!stream) { - return _end.call(this, chunk, encoding) + return _end.call(this, chunk, encoding, callback) } // mark ended ended = true + if (chunk) { + chunk = toBuffer(chunk, encoding) + if (isOldRuntime && stream) { + encoding = callback + } + } + // write Buffer for Node.js 0.8 return chunk - ? stream.end(toBuffer(chunk, encoding)) - : stream.end() + ? stream.end(chunk, encoding, callback) + : stream.end(chunk, callback) } res.on = function on (type, listener) { @@ -202,6 +270,10 @@ function compression (options) { res.removeHeader('Content-Length') // compression + stream.on('error', function (err) { + res.emit('error', err) + }) + stream.on('data', function onStreamData (chunk) { if (_write.call(res, chunk) === false) { stream.pause() diff --git a/test/compression.js b/test/compression.js index 6975ea0b..a43ed668 100644 --- a/test/compression.js +++ b/test/compression.js @@ -10,6 +10,213 @@ var zlib = require('zlib') var compression = require('..') describe('compression()', function () { + describe('should work with valid types (string, Buffer, Uint8Array)', function () { + it('res.write(string)', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end('hello world') + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip') + .expect('Content-Encoding', 'gzip') + .expect(200, done) + }) + + it('res.write(Buffer)', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end(Buffer.from('hello world')) + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip') + .expect('Content-Encoding', 'gzip') + .expect(200, done) + }) + + it('res.end(cb)', function (done) { + var callbackCalled = false + + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.write(Buffer.from('hello world')) + res.end(function () { + callbackCalled = true + }) + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip') + .expect('Content-Encoding', 'gzip') + .expect(200, function () { + assert.ok(callbackCalled) + done() + }) + }) + + it('res.end(string, cb)', function (done) { + var callbackCalled = false + + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end(Buffer.from('hello world'), function () { + callbackCalled = true + }) + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip') + .expect('Content-Encoding', 'gzip') + .expect(200, function () { + assert.ok(callbackCalled) + done() + }) + }) + + var run = /^v0\.12\./.test(process.version) ? it : it.skip + run('res.write(Uint8Array)', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + res.end(new Uint8Array(1)) + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip') + .expect('Content-Encoding', 'gzip') + .expect(200, done) + }) + }) + + describe('should throw with invalid types', function () { + it('res.write(1) should fire ERR_INVALID_ARG_TYPE', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + try { + res.write(1) + } catch (err) { + assert.ok(err.toString().indexOf('TypeError') > -1 || err.code === 'ERR_INVALID_ARG_TYPE') + res.flush() + res.end() + } + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip') + .expect(200, done) + }) + + it('res.write({}) should fire ERR_INVALID_ARG_TYPE', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + try { + res.write({}) + } catch (err) { + assert.ok(err.toString().indexOf('TypeError') > -1 || err.code === 'ERR_INVALID_ARG_TYPE') + res.flush() + res.end() + } + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip') + .expect(200, done) + }) + + it('res.write(null) should fire ERR_INVALID_ARG_TYPE or ERR_STREAM_NULL_VALUES', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + try { + res.write(null) + } catch (err) { + assert.ok(err.toString().indexOf('TypeError') > -1 || err.code === 'ERR_INVALID_ARG_TYPE' || err.code === 'ERR_STREAM_NULL_VALUES') + res.flush() + res.end() + } + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip') + .expect(200, done) + }) + }) + + it('res.write() should return false or throw ERR_STREAM_ALREADY_FINISHED when stream is already finished', function (done) { + var onError = function (err) { + assert.ok(err.toString().indexOf('write after end') > -1 || err.code === 'ERR_STREAM_WRITE_AFTER_END') + } + var server = createServer({ threshold: 0 }, function (req, res) { + res.on('error', onError) + res.setHeader('Content-Type', 'text/plain') + res.end('hello world') + + var canWrite = res.write('hola', function (err) { + assert.ok(err.toString().indexOf('write after end') > -1 || err.code === 'ERR_STREAM_ALREADY_FINISHED') + }) + + assert.ok(!canWrite) + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip') + .expect(shouldHaveHeader('Content-Encoding')) + .expect(shouldHaveBodyLength('hello world'.length)) + .expect(200, done) + }) + + var run = /^v0\.12\./.test(process.version) ? it : it.skip + run('res.write() should call callback if passsed', function (done) { + var server = createServer({ threshold: 0 }, function (req, res) { + res.setHeader('Content-Type', 'text/plain') + + res.write('hello, world', function () { + res.end() + }) + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip') + .expect(shouldHaveHeader('Content-Encoding')) + .expect(shouldHaveBodyLength('hello, world'.length)) + .expect(200, done) + }) + + run = /^v0\.12\./.test(process.version) ? it : it.skip + run('res.write() should call callback with error after end', function (done) { + var onErrorCalled = false + var onError = function (err) { + assert.ok(err.toString().indexOf('write after end') > -1 || err.code === 'ERR_STREAM_WRITE_AFTER_END') + onErrorCalled = true + } + + var server = createServer({ threshold: 0 }, function (req, res) { + res.on('error', onError) + res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') + + res.write('hello, world', onError) + + process.nextTick(function () { + assert.ok(onErrorCalled) + }) + }) + + request(server) + .get('/') + .set('Accept-Encoding', 'gzip') + .end(done) + }) + it('should skip HEAD', function (done) { var server = createServer({ threshold: 0 }, function (req, res) { res.setHeader('Content-Type', 'text/plain') @@ -437,17 +644,32 @@ describe('compression()', function () { }) it('should return false writing after end', function (done) { + var onErrorCalled = false + var onError = function (err) { + assert.ok(err.toString().indexOf('write after end') > -1 || err.code === 'ERR_STREAM_WRITE_AFTER_END') + onErrorCalled = true + } + var server = createServer({ threshold: 0 }, function (req, res) { + res.on('error', onError) res.setHeader('Content-Type', 'text/plain') + res.end('hello, world') - assert.ok(res.write() === false) - assert.ok(res.end() === false) + + assert.ok(res.write('', onError) === false) + + process.nextTick(function () { + var run = /^v0\.12\./.test(process.version) + if (!run) return + assert.ok(onErrorCalled) + }) }) request(server) .get('/') .set('Accept-Encoding', 'gzip') - .expect('Content-Encoding', 'gzip', done) + .expect('Content-Encoding', 'gzip') + .end(done) }) }) @@ -659,9 +881,12 @@ describe('compression()', function () { }) }) -function createServer (opts, fn) { +function createServer (opts, fn, t) { var _compression = compression(opts) return http.createServer(function (req, res) { + if (t) { + res.on('finish', function () { console.log(t.title, 'server closed') }) + } _compression(req, res, function (err) { if (err) { res.statusCode = err.status || 500 @@ -680,6 +905,13 @@ function shouldHaveBodyLength (length) { } } +function shouldHaveHeader (header) { + return function (res) { + var ok = (header.toLowerCase() in res.headers) + assert.ok(ok, 'should have header ' + header) + } +} + function shouldNotHaveHeader (header) { return function (res) { assert.ok(!(header.toLowerCase() in res.headers), 'should not have header ' + header)