Skip to content

Commit 871a3c4

Browse files
authoredSep 2, 2018
Merge pull request #231 from jfhbrook/revert-230-revert-228-brotli-encoding
Fix and pull in brotli encoding (aka `Revert "Revert "Add support for brotli encoding""`)
2 parents fe91caf + c09621f commit 871a3c4

14 files changed

+264
-13
lines changed
 

‎.gitignore

-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ coverage
1111
*.dat
1212
*.out
1313
*.pid
14-
*.gz
1514

1615
pids
1716
logs

‎README.md

+11
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ const opts = {
9797
cache: 'max-age=3600',
9898
cors: false,
9999
gzip: true,
100+
brotli: false,
100101
defaultExt: 'html',
101102
handleError: true,
102103
serverHeader: true,
@@ -209,6 +210,16 @@ that the behavior is appropriate. If `./public/some-file.js.gz` is not valid
209210
gzip, this will fall back to `./public/some-file.js`. You can turn this off
210211
with `opts.gzip === false`.
211212

213+
### `opts.brotli`
214+
### `--brotli`
215+
216+
Serve `./public/some-file.js.br` in place of `./public/some-file.js` when the
217+
[brotli encoded](https://github.com/google/brotli) version exists and ecstatic
218+
determines that the behavior is appropriate. If the request does not contain
219+
`br` in the HTTP `accept-encoding` header, ecstatic will instead attempt to
220+
serve a gzipped version (if `opts.gzip` is `true`), or fall back to
221+
`./public.some-file.js`. Defaults to **false**.
222+
212223
### `opts.serverHeader`
213224
### `--no-server-header`
214225

‎lib/ecstatic.js

+50-12
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function decodePathname(pathname) {
3232

3333

3434
// Check to see if we should try to compress a file with gzip.
35-
function shouldCompress(req) {
35+
function shouldCompressGzip(req) {
3636
const headers = req.headers;
3737

3838
return headers && headers['accept-encoding'] &&
@@ -42,6 +42,16 @@ function shouldCompress(req) {
4242
;
4343
}
4444

45+
function shouldCompressBrotli(req) {
46+
const headers = req.headers;
47+
48+
return headers && headers['accept-encoding'] &&
49+
headers['accept-encoding']
50+
.split(',')
51+
.some(el => ['*', 'br'].indexOf(el.trim()) !== -1)
52+
;
53+
}
54+
4555
function hasGzipId12(gzipped, cb) {
4656
const stream = fs.createReadStream(gzipped, { start: 0, end: 1 });
4757
let buffer = Buffer('');
@@ -166,7 +176,8 @@ module.exports = function createMiddleware(_dir, _options) {
166176
const parsed = url.parse(req.url);
167177
let pathname = null;
168178
let file = null;
169-
let gzipped = null;
179+
let gzippedFile = null;
180+
let brotliFile = null;
170181

171182
// Strip any null bytes from the url
172183
// This was at one point necessary because of an old bug in url.parse
@@ -198,7 +209,9 @@ module.exports = function createMiddleware(_dir, _options) {
198209
path.relative(path.join('/', baseDir), pathname)
199210
)
200211
);
201-
gzipped = `${file}.gz`;
212+
// determine compressed forms if they were to exist
213+
gzippedFile = `${file}.gz`;
214+
brotliFile = `${file}.br`;
202215

203216
if (serverHeader !== false) {
204217
// Set common headers.
@@ -229,7 +242,7 @@ module.exports = function createMiddleware(_dir, _options) {
229242

230243
function serve(stat) {
231244
// Do a MIME lookup, fall back to octet-stream and handle gzip
232-
// special case.
245+
// and brotli special case.
233246
const defaultType = opts.contentType || 'application/octet-stream';
234247
let contentType = mime.lookup(file, defaultType);
235248
let charSet;
@@ -238,19 +251,21 @@ module.exports = function createMiddleware(_dir, _options) {
238251
const etag = generateEtag(stat, weakEtags);
239252
let cacheControl = cache;
240253
let stream = null;
241-
242254
if (contentType) {
243255
charSet = mime.charsets.lookup(contentType, 'utf-8');
244256
if (charSet) {
245257
contentType += `; charset=${charSet}`;
246258
}
247259
}
248260

249-
if (file === gzipped) { // is .gz picked up
261+
if (file === gzippedFile) { // is .gz picked up
250262
res.setHeader('Content-Encoding', 'gzip');
251-
252263
// strip gz ending and lookup mime type
253264
contentType = mime.lookup(path.basename(file, '.gz'), defaultType);
265+
} else if (file === brotliFile) { // is .br picked up
266+
res.setHeader('Content-Encoding', 'br');
267+
// strip br ending and lookup mime type
268+
contentType = mime.lookup(path.basename(file, '.br'), defaultType);
254269
}
255270

256271
if (typeof cacheControl === 'function') {
@@ -401,13 +416,13 @@ module.exports = function createMiddleware(_dir, _options) {
401416
});
402417
}
403418

404-
// Look for a gzipped file if this is turned on
405-
if (opts.gzip && shouldCompress(req)) {
406-
fs.stat(gzipped, (err, stat) => {
419+
// serve gzip file if exists and is valid
420+
function tryServeWithGzip() {
421+
fs.stat(gzippedFile, (err, stat) => {
407422
if (!err && stat.isFile()) {
408-
hasGzipId12(gzipped, (gzipErr, isGzip) => {
423+
hasGzipId12(gzippedFile, (gzipErr, isGzip) => {
409424
if (!gzipErr && isGzip) {
410-
file = gzipped;
425+
file = gzippedFile;
411426
serve(stat);
412427
} else {
413428
statFile();
@@ -417,6 +432,29 @@ module.exports = function createMiddleware(_dir, _options) {
417432
statFile();
418433
}
419434
});
435+
}
436+
437+
// serve brotli file if exists, otherwise try gzip
438+
function tryServeWithBrotli(shouldTryGzip) {
439+
fs.stat(brotliFile, (err, stat) => {
440+
if (!err && stat.isFile()) {
441+
file = brotliFile;
442+
serve(stat);
443+
} else if (shouldTryGzip) {
444+
tryServeWithGzip();
445+
} else {
446+
statFile();
447+
}
448+
});
449+
}
450+
451+
const shouldTryBrotli = opts.brotli && shouldCompressBrotli(req);
452+
const shouldTryGzip = opts.gzip && shouldCompressGzip(req);
453+
// always try brotli first, next try gzip, finally serve without compression
454+
if (shouldTryBrotli) {
455+
tryServeWithBrotli(shouldTryGzip);
456+
} else if (shouldTryGzip) {
457+
tryServeWithGzip();
420458
} else {
421459
statFile();
422460
}

‎lib/ecstatic/defaults.json

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"cache": "max-age=3600",
99
"cors": false,
1010
"gzip": true,
11+
"brotli": false,
1112
"defaultExt": ".html",
1213
"handleError": true,
1314
"serverHeader": true,

‎lib/ecstatic/opts.js

+6
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module.exports = (opts) => {
1414
let si = defaults.si;
1515
let cache = defaults.cache;
1616
let gzip = defaults.gzip;
17+
let brotli = defaults.brotli;
1718
let defaultExt = defaults.defaultExt;
1819
let handleError = defaults.handleError;
1920
const headers = {};
@@ -105,6 +106,10 @@ module.exports = (opts) => {
105106
gzip = opts.gzip;
106107
}
107108

109+
if (typeof opts.brotli !== 'undefined' && opts.brotli !== null) {
110+
brotli = opts.brotli;
111+
}
112+
108113
aliases.handleError.some((k) => {
109114
if (isDeclared(k)) {
110115
handleError = opts[k];
@@ -195,6 +200,7 @@ module.exports = (opts) => {
195200
defaultExt,
196201
baseDir: (opts && opts.baseDir) || '/',
197202
gzip,
203+
brotli,
198204
handleError,
199205
headers,
200206
serverHeader,

‎test/compression.js

+187
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
'use strict';
2+
3+
const test = require('tap').test;
4+
const ecstatic = require('../lib/ecstatic');
5+
const http = require('http');
6+
const request = require('request');
7+
8+
const root = `${__dirname}/public`;
9+
10+
test('serves brotli-encoded file when available', (t) => {
11+
t.plan(3);
12+
13+
const server = http.createServer(ecstatic({
14+
root,
15+
brotli: true,
16+
autoIndex: true
17+
}));
18+
19+
server.listen(() => {
20+
const port = server.address().port;
21+
const options = {
22+
uri: `http://localhost:${port}/brotli`,
23+
headers: {
24+
'accept-encoding': 'gzip, deflate, br'
25+
}
26+
};
27+
28+
request.get(options, (err, res) => {
29+
t.ifError(err);
30+
t.equal(res.statusCode, 200);
31+
t.equal(res.headers['content-encoding'], 'br');
32+
});
33+
});
34+
t.once('end', () => {
35+
server.close();
36+
});
37+
});
38+
39+
test('serves gzip-encoded file when brotli not available', (t) => {
40+
t.plan(3);
41+
42+
const server = http.createServer(ecstatic({
43+
root,
44+
brotli: true,
45+
gzip: true,
46+
autoIndex: true
47+
}));
48+
49+
server.listen(() => {
50+
const port = server.address().port;
51+
const options = {
52+
uri: `http://localhost:${port}/gzip`,
53+
headers: {
54+
'accept-encoding': 'gzip, deflate, br'
55+
}
56+
};
57+
58+
request.get(options, (err, res) => {
59+
t.ifError(err);
60+
t.equal(res.statusCode, 200);
61+
t.equal(res.headers['content-encoding'], 'gzip');
62+
});
63+
});
64+
t.once('end', () => {
65+
server.close();
66+
});
67+
});
68+
69+
test('serves gzip-encoded file when brotli not accepted', (t) => {
70+
t.plan(3);
71+
72+
const server = http.createServer(ecstatic({
73+
root,
74+
brotli: true,
75+
gzip: true,
76+
autoIndex: true
77+
}));
78+
79+
server.listen(() => {
80+
const port = server.address().port;
81+
const options = {
82+
uri: `http://localhost:${port}/brotli`,
83+
headers: {
84+
'accept-encoding': 'gzip, deflate'
85+
}
86+
};
87+
88+
request.get(options, (err, res) => {
89+
t.ifError(err);
90+
t.equal(res.statusCode, 200);
91+
t.equal(res.headers['content-encoding'], 'gzip');
92+
});
93+
});
94+
t.once('end', () => {
95+
server.close();
96+
});
97+
});
98+
99+
test('serves gzip-encoded file when brotli not enabled', (t) => {
100+
t.plan(3);
101+
102+
const server = http.createServer(ecstatic({
103+
root,
104+
brotli: false,
105+
gzip: true,
106+
autoIndex: true
107+
}));
108+
109+
server.listen(() => {
110+
const port = server.address().port;
111+
const options = {
112+
uri: `http://localhost:${port}/brotli`,
113+
headers: {
114+
'accept-encoding': 'gzip, deflate, br'
115+
}
116+
};
117+
118+
request.get(options, (err, res) => {
119+
t.ifError(err);
120+
t.equal(res.statusCode, 200);
121+
t.equal(res.headers['content-encoding'], 'gzip');
122+
});
123+
});
124+
t.once('end', () => {
125+
server.close();
126+
});
127+
});
128+
129+
test('serves unencoded file when compression not accepted', (t) => {
130+
t.plan(3);
131+
132+
const server = http.createServer(ecstatic({
133+
root,
134+
brotli: true,
135+
gzip: true,
136+
autoIndex: true
137+
}));
138+
139+
server.listen(() => {
140+
const port = server.address().port;
141+
const options = {
142+
uri: `http://localhost:${port}/brotli`,
143+
headers: {
144+
'accept-encoding': ''
145+
}
146+
};
147+
148+
request.get(options, (err, res) => {
149+
t.ifError(err);
150+
t.equal(res.statusCode, 200);
151+
t.equal(res.headers['content-encoding'], undefined);
152+
});
153+
});
154+
t.once('end', () => {
155+
server.close();
156+
});
157+
});
158+
159+
test('serves unencoded file when compression not enabled', (t) => {
160+
t.plan(3);
161+
162+
const server = http.createServer(ecstatic({
163+
root,
164+
brotli: false,
165+
gzip: false,
166+
autoIndex: true
167+
}));
168+
169+
server.listen(() => {
170+
const port = server.address().port;
171+
const options = {
172+
uri: `http://localhost:${port}/brotli`,
173+
headers: {
174+
'accept-encoding': 'gzip, deflate, br'
175+
}
176+
};
177+
178+
request.get(options, (err, res) => {
179+
t.ifError(err);
180+
t.equal(res.statusCode, 200);
181+
t.equal(res.headers['content-encoding'], undefined);
182+
});
183+
});
184+
t.once('end', () => {
185+
server.close();
186+
});
187+
});

‎test/public/brotli/fake_ecstatic

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ecstatic

‎test/public/brotli/fake_ecstatic.br

11 Bytes
Binary file not shown.

‎test/public/brotli/index.html

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
brotli, but I'm not compressed!!!

‎test/public/brotli/index.html.br

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
2+
�brotli, compressed!!
3+


‎test/public/brotli/index.html.gz

50 Bytes
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
You've been duped! This is not compressed!

‎test/public/brotli/real_ecstatic

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ecstatic

0 commit comments

Comments
 (0)