Skip to content

Commit 5ab6b88

Browse files
committed
feat(brotli): support brotli compression format
1 parent 3317168 commit 5ab6b88

File tree

7 files changed

+284
-35
lines changed

7 files changed

+284
-35
lines changed

package.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"browser": {
77
"./src/node/index.js": "./src/client.js",
88
"./lib/node/index.js": "./lib/client.js",
9-
"./test/support/server.js": "./test/support/blank.js"
9+
"./test/support/server.js": "./test/support/blank.js",
10+
"re2": false
1011
},
1112
"bugs": {
1213
"url": "https://github.com/ladjs/superagent/issues"
@@ -26,7 +27,8 @@
2627
"formidable": "^3.5.1",
2728
"methods": "^1.1.2",
2829
"mime": "2.6.0",
29-
"qs": "^6.11.0"
30+
"qs": "^6.11.0",
31+
"re2": "^1.21.3"
3032
},
3133
"devDependencies": {
3234
"@babel/cli": "^7.20.7",
@@ -62,7 +64,7 @@
6264
"rimraf": "3",
6365
"should": "^13.2.3",
6466
"should-http": "^0.1.1",
65-
"tinyify": "3.0.0",
67+
"tinyify": "4.0.0",
6668
"xo": "^0.53.1",
6769
"zuul": "^3.12.0"
6870
},

src/node/decompress.js

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
const zlib = require('zlib');
2+
const utils = require('../utils');
3+
const { isGzipOrDeflateEncoding, isBrotliEncoding } = utils;
4+
5+
exports.chooseDecompresser = (res) => {
6+
let decompresser;
7+
if (isGzipOrDeflateEncoding(res)) {
8+
decompresser = zlib.createUnzip();
9+
} else if (isBrotliEncoding(res)) {
10+
decompresser = zlib.createBrotliDecompress();
11+
} else {
12+
throw new Error('unknown content-encoding');
13+
}
14+
return decompresser;
15+
}

src/node/index.js

+31-24
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ const safeStringify = require('fast-safe-stringify');
2121
const utils = require('../utils');
2222
const RequestBase = require('../request-base');
2323
const http2 = require('./http2wrapper');
24-
const { unzip } = require('./unzip');
24+
const { decompress } = require('./unzip');
2525
const Response = require('./response');
2626

27-
const { mixin, hasOwn } = utils;
27+
const { mixin, hasOwn, isBrotliEncoding, isGzipOrDeflateEncoding } = utils;
28+
const { chooseDecompresser } = require('./decompress');
2829

2930
function request(method, url) {
3031
// callback
@@ -446,9 +447,11 @@ Request.prototype._pipeContinue = function (stream, options) {
446447
this._emitResponse();
447448
if (this._aborted) return;
448449

449-
if (this._shouldUnzip(res)) {
450-
const unzipObject = zlib.createUnzip();
451-
unzipObject.on('error', (error) => {
450+
if (this._shouldDecompress(res)) {
451+
452+
let decompresser = chooseDecompresser(res);
453+
454+
decompresser.on('error', (error) => {
452455
if (error && error.code === 'Z_BUF_ERROR') {
453456
// unexpected end of file is ignored by browsers and curl
454457
stream.emit('end');
@@ -457,9 +460,9 @@ Request.prototype._pipeContinue = function (stream, options) {
457460

458461
stream.emit('error', error);
459462
});
460-
res.pipe(unzipObject).pipe(stream, options);
461-
// don't emit 'end' until unzipObject has completed writing all its data.
462-
unzipObject.once('end', () => this.emit('end'));
463+
res.pipe(decompresser).pipe(stream, options);
464+
// don't emit 'end' until decompresser has completed writing all its data.
465+
decompresser.once('end', () => this.emit('end'));
463466
} else {
464467
res.pipe(stream, options);
465468
res.once('end', () => this.emit('end'));
@@ -1045,8 +1048,8 @@ Request.prototype._end = function () {
10451048
}
10461049

10471050
// zlib support
1048-
if (this._shouldUnzip(res)) {
1049-
unzip(req, res);
1051+
if (this._shouldDecompress(res)) {
1052+
decompress(req, res);
10501053
}
10511054

10521055
let buffer = this._buffer;
@@ -1275,22 +1278,11 @@ Request.prototype._end = function () {
12751278
};
12761279

12771280
// Check whether response has a non-0-sized gzip-encoded body
1278-
Request.prototype._shouldUnzip = (res) => {
1279-
if (res.statusCode === 204 || res.statusCode === 304) {
1280-
// These aren't supposed to have any body
1281-
return false;
1282-
}
1283-
1284-
// header content is a string, and distinction between 0 and no information is crucial
1285-
if (res.headers['content-length'] === '0') {
1286-
// We know that the body is empty (unfortunately, this check does not cover chunked encoding)
1287-
return false;
1288-
}
1289-
1290-
// console.log(res);
1291-
return /^\s*(?:deflate|gzip)\s*$/.test(res.headers['content-encoding']);
1281+
Request.prototype._shouldDecompress = (res) => {
1282+
return hasNonEmptyResponseContent(res) && (isGzipOrDeflateEncoding(res) || isBrotliEncoding(res));
12921283
};
12931284

1285+
12941286
/**
12951287
* Overrides DNS for selected hostnames. Takes object mapping hostnames to IP addresses.
12961288
*
@@ -1411,3 +1403,18 @@ function isJSON(mime) {
14111403
function isRedirect(code) {
14121404
return [301, 302, 303, 305, 307, 308].includes(code);
14131405
}
1406+
1407+
function hasNonEmptyResponseContent(res) {
1408+
if (res.statusCode === 204 || res.statusCode === 304) {
1409+
// These aren't supposed to have any body
1410+
return false;
1411+
}
1412+
1413+
// header content is a string, and distinction between 0 and no information is crucial
1414+
if (res.headers['content-length'] === '0') {
1415+
// We know that the body is empty (unfortunately, this check does not cover chunked encoding)
1416+
return false;
1417+
}
1418+
1419+
return true;
1420+
}

src/node/unzip.js

+9-8
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,26 @@
44

55
const { StringDecoder } = require('string_decoder');
66
const Stream = require('stream');
7-
const zlib = require('zlib');
7+
const { chooseDecompresser } = require('./decompress');
88

99
/**
10-
* Buffers response data events and re-emits when they're unzipped.
10+
* Buffers response data events and re-emits when they're decompressed.
1111
*
1212
* @param {Request} req
1313
* @param {Response} res
1414
* @api private
1515
*/
1616

17-
exports.unzip = (request, res) => {
18-
const unzip = zlib.createUnzip();
17+
exports.decompress = (request, res) => {
18+
let decompresser = chooseDecompresser(res);
19+
1920
const stream = new Stream();
2021
let decoder;
2122

2223
// make node responseOnEnd() happy
2324
stream.req = request;
2425

25-
unzip.on('error', (error) => {
26+
decompresser.on('error', (error) => {
2627
if (error && error.code === 'Z_BUF_ERROR') {
2728
// unexpected end of file is ignored by browsers and curl
2829
stream.emit('end');
@@ -33,15 +34,15 @@ exports.unzip = (request, res) => {
3334
});
3435

3536
// pipe to unzip
36-
res.pipe(unzip);
37+
res.pipe(decompresser);
3738

3839
// override `setEncoding` to capture encoding
3940
res.setEncoding = (type) => {
4041
decoder = new StringDecoder(type);
4142
};
4243

4344
// decode upon decompressing with captured encoding
44-
unzip.on('data', (buf) => {
45+
decompresser.on('data', (buf) => {
4546
if (decoder) {
4647
const string_ = decoder.write(buf);
4748
if (string_.length > 0) stream.emit('data', string_);
@@ -50,7 +51,7 @@ exports.unzip = (request, res) => {
5051
}
5152
});
5253

53-
unzip.on('end', () => {
54+
decompresser.on('end', () => {
5455
stream.emit('end');
5556
});
5657

src/utils.js

+32
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
let RE2;
2+
let hasRE2 = true;
3+
4+
try {
5+
RE2 = require('re2');
6+
} catch {
7+
hasRE2 = false;
8+
}
9+
10+
const SafeRegExp = hasRE2 ? RE2 : RegExp;
11+
12+
113
/**
214
* Return the mime type for the given `str`.
315
*
@@ -105,3 +117,23 @@ exports.mixin = (target, source) => {
105117
}
106118
}
107119
};
120+
121+
/**
122+
* Check if the response is compressed using Gzip or Deflate.
123+
* @param {Object} res
124+
* @return {Boolean}
125+
*/
126+
127+
exports.isGzipOrDeflateEncoding = (res) => {
128+
return new SafeRegExp(/^\s*(?:deflate|gzip)\s*$/).test(res.headers['content-encoding']);
129+
};
130+
131+
/**
132+
* Check if the response is compressed using Brotli.
133+
* @param {Object} res
134+
* @return {Boolean}
135+
*/
136+
137+
exports.isBrotliEncoding = (res) => {
138+
return new SafeRegExp(/^\s*(?:br)\s*$/).test(res.headers['content-encoding']);
139+
};

test/node/inflate.js

+84
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,38 @@ app.get('/binary', (request_, res) => {
3636
res.send(buf);
3737
});
3838
});
39+
40+
app.get('/binary-brotli', (request_, res) => {
41+
zlib.brotliCompress(subject, (error, buf) => {
42+
res.set('Content-Encoding', 'br');
43+
res.send(buf);
44+
});
45+
});
46+
3947
app.get('/corrupt', (request_, res) => {
4048
res.set('Content-Encoding', 'gzip');
4149
res.send('blah');
4250
});
4351

52+
app.get('/corrupt-brotli', (request_, res) => {
53+
res.set('Content-Encoding', 'br');
54+
res.send('blah');
55+
});
56+
4457
app.get('/nocontent', (request_, res, next) => {
4558
res.statusCode = 204;
4659
res.set('Content-Type', 'text/plain');
4760
res.set('Content-Encoding', 'gzip');
4861
res.send('');
4962
});
5063

64+
app.get('/nocontent-brotli', (request_, res, next) => {
65+
res.statusCode = 204;
66+
res.set('Content-Type', 'text/plain');
67+
res.set('Content-Encoding', 'br');
68+
res.send('');
69+
});
70+
5171
app.get('/', (request_, res, next) => {
5272
zlib.deflate(subject, (error, buf) => {
5373
res.set('Content-Type', 'text/plain');
@@ -65,6 +85,15 @@ app.get('/junk', (request_, res) => {
6585
});
6686
});
6787

88+
app.get('/junk-brotli', (request_, res) => {
89+
zlib.brotliCompress(subject, (error, buf) => {
90+
res.set('Content-Type', 'text/plain');
91+
res.set('Content-Encoding', 'br');
92+
res.write(buf);
93+
res.end(' 0 junk');
94+
});
95+
});
96+
6897
app.get('/chopped', (request_, res) => {
6998
zlib.deflate(`${subject}123456`, (error, buf) => {
7099
res.set('Content-Type', 'text/plain');
@@ -73,6 +102,14 @@ app.get('/chopped', (request_, res) => {
73102
});
74103
});
75104

105+
app.get('/chopped-brotli', (request_, res) => {
106+
zlib.brotliCompress(`${subject}123456`, (error, buf) => {
107+
res.set('Content-Type', 'text/plain');
108+
res.set('Content-Encoding', 'br');
109+
res.send(buf.slice(0, -1));
110+
});
111+
});
112+
76113
describe('zlib', () => {
77114
it('should deflate the content', (done) => {
78115
request.get(base).end((error, res) => {
@@ -106,6 +143,14 @@ describe('zlib', () => {
106143
});
107144
});
108145

146+
it('should ignore trailing junk-brotli', (done) => {
147+
request.get(`${base}/junk-brotli`).end((error, res) => {
148+
res.should.have.status(200);
149+
res.text.should.equal(subject);
150+
done();
151+
});
152+
});
153+
109154
it('should ignore missing data', (done) => {
110155
request.get(`${base}/chopped`).end((error, res) => {
111156
assert.equal(undefined, error);
@@ -115,6 +160,15 @@ describe('zlib', () => {
115160
});
116161
});
117162

163+
it('should ignore missing brotli data', (done) => {
164+
request.get(`${base}/chopped-brotli`).end((error, res) => {
165+
assert.equal(undefined, error);
166+
res.should.have.status(200);
167+
res.text.should.startWith(subject);
168+
done();
169+
});
170+
});
171+
118172
it('should handle corrupted responses', (done) => {
119173
request.get(`${base}/corrupt`).end((error, res) => {
120174
assert(error, 'missing error');
@@ -123,6 +177,13 @@ describe('zlib', () => {
123177
});
124178
});
125179

180+
it('should handle brotli corrupted responses', (done) => {
181+
request.get(`${base}/corrupt-brotli`).end((error, res) => {
182+
res.text.should.equal('');
183+
done();
184+
});
185+
});
186+
126187
it('should handle no content with gzip header', (done) => {
127188
request.get(`${base}/nocontent`).end((error, res) => {
128189
assert.ifError(error);
@@ -134,6 +195,17 @@ describe('zlib', () => {
134195
});
135196
});
136197

198+
it('should handle no content with gzip header', (done) => {
199+
request.get(`${base}/nocontent-brotli`).end((error, res) => {
200+
assert.ifError(error);
201+
assert(res);
202+
res.should.have.status(204);
203+
res.text.should.equal('');
204+
res.headers.should.not.have.property('content-length');
205+
done();
206+
});
207+
});
208+
137209
describe('without encoding set', () => {
138210
it('should buffer if asked', () => {
139211
return request
@@ -147,6 +219,18 @@ describe('zlib', () => {
147219
});
148220
});
149221

222+
it('should buffer Brotli if asked', () => {
223+
return request
224+
.get(`${base}/binary-brotli`)
225+
.buffer(true)
226+
.then((res) => {
227+
res.should.have.status(200);
228+
assert(res.headers['content-length']);
229+
assert(res.body.byteLength);
230+
assert.equal(subject, res.body.toString());
231+
});
232+
});
233+
150234
it('should emit buffers', (done) => {
151235
request.get(`${base}/binary`).end((error, res) => {
152236
res.should.have.status(200);

0 commit comments

Comments
 (0)