diff --git a/README.md b/README.md index ba9afb8..7801bac 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ app.use(staticCache(path.join(__dirname, 'public'), { - `options.buffer` (bool) - store the files in memory instead of streaming from the filesystem on each request. - `options.gzip` (bool) - when request's accept-encoding include gzip, files will compressed by gzip. - `options.usePrecompiledGzip` (bool) - try use gzip files, loaded from disk, like nginx gzip_static +- `options.brotli` (bool) - when request's accept-encoding include br, files will compressed by brotli. +- `options.usePrecompiledBrotli` (bool) - try use brotli files, loaded from disk - `options.alias` (obj) - object map of aliases. See below. - `options.prefix` (str) - the url prefix you wish to add, default to `''`. - `options.dynamic` (bool) - dynamic load file which not cached on initialization. diff --git a/index.js b/index.js index 7b1f8d2..5efda2c 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,7 @@ var mime = require('mime-types') var compressible = require('compressible') var readDir = require('fs-readdir-recursive') var debug = require('debug')('koa-static-cache') +var util = require('util'); module.exports = function staticCache(dir, options, files) { if (typeof dir === 'object') { @@ -20,6 +21,7 @@ module.exports = function staticCache(dir, options, files) { files = new FileManager(files || options.files) dir = dir || options.dir || process.cwd() dir = path.normalize(dir) + var enableBrotli = !!options.brotli var enableGzip = !!options.gzip var filePrefix = path.normalize(options.prefix.replace(/^\//, '')) @@ -79,7 +81,7 @@ module.exports = function staticCache(dir, options, files) { ctx.status = 200 - if (enableGzip) ctx.vary('Accept-Encoding') + if (enableBrotli || enableGzip) ctx.vary('Accept-Encoding') if (!file.buffer) { var stats = await fs.stat(file.path) @@ -102,10 +104,14 @@ module.exports = function staticCache(dir, options, files) { if (ctx.method === 'HEAD') return + var acceptBrotli = ctx.acceptsEncodings('br') === 'br' var acceptGzip = ctx.acceptsEncodings('gzip') === 'gzip' if (file.zipBuffer) { - if (acceptGzip) { + if (acceptBrotli) { + ctx.set('content-encoding', 'br') + ctx.body = file.brBuffer + } else if (acceptGzip) { ctx.set('content-encoding', 'gzip') ctx.body = file.zipBuffer } else { @@ -114,13 +120,27 @@ module.exports = function staticCache(dir, options, files) { return } + var shouldBrotli = enableBrotli + && file.length > 1024 + && acceptBrotli + && compressible(file.type) var shouldGzip = enableGzip && file.length > 1024 && acceptGzip && compressible(file.type) if (file.buffer) { - if (shouldGzip) { + if (shouldBrotli) { + + var brFile = files.get(filename + '.br') + if (options.usePrecompiledBrotli && brFile && brFile.buffer) { // if .br file already read from disk + file.brBuffer = brFile.buffer + } else { + file.brBuffer = await util.promisify(zlib.brotliCompress)(file.buffer) + } + ctx.set('content-encoding', 'br') + ctx.body = file.brBuffer + } else if (shouldGzip) { var gzFile = files.get(filename + '.gz') if (options.usePrecompiledGzip && gzFile && gzFile.buffer) { // if .gz file already read from disk @@ -148,8 +168,12 @@ module.exports = function staticCache(dir, options, files) { } ctx.body = stream - // enable gzip will remove content length - if (shouldGzip) { + // enable brotli/gzip will remove content length + if (shouldBrotli) { + ctx.remove('content-length') + ctx.set('content-encoding', 'br') + ctx.body = stream.pipe(zlib.createBrotliCompress()) + } else if (shouldGzip) { ctx.remove('content-length') ctx.set('content-encoding', 'gzip') ctx.body = stream.pipe(zlib.createGzip()) diff --git a/test/index.js b/test/index.js index a6c28b0..8d5f0d8 100644 --- a/test/index.js +++ b/test/index.js @@ -63,6 +63,26 @@ app5.use(staticCache({ })) var server5 = http.createServer(app5.callback()) +var app6 = new Koa() +app6.use(staticCache(path.join(__dirname, '..'), { + buffer: true, + brotli: true, + filter(file) { + return !file.includes('node_modules') + } +})) +var server6 = http.createServer(app6.callback()) + +var app7 = new Koa() +var files7 = {} +app7.use(staticCache(path.join(__dirname, '..'), { + brotli: true, + filter(file) { + return !file.includes('node_modules') + } +}, files7)) +var server7 = http.createServer(app7.callback()) + describe('Static Cache', function () { it('should dir priority than options.dir', function (done) { @@ -601,4 +621,85 @@ describe('Static Cache', function () { .expect(404) .end(done) }) + + it('should serve files with brotli buffer', function (done) { + var index = fs.readFileSync('index.js') + zlib.brotliCompress(index, function (err, content) { + request(server6) + .get('/index.js') + .set('Accept-Encoding', 'br') + .responseType('arraybuffer') + .expect(200) + .expect('Cache-Control', 'public, max-age=0') + .expect('Content-Encoding', 'br') + .expect('Content-Type', /javascript/) + .expect('Content-Length', String(content.length)) + .expect('Vary', 'Accept-Encoding') + .expect(function (res) { + return res.body.toString('hex') === content.toString('hex') + }) + .end(function (err, res) { + if (err) + return done(err) + res.should.have.header('Content-Length') + res.should.have.header('Last-Modified') + res.should.have.header('ETag') + + etag = res.headers.etag + + done() + }) + }) + }) + + it('should not serve files with brotli buffer when accept encoding not include br', + function (done) { + var index = fs.readFileSync('index.js') + request(server6) + .get('/index.js') + .set('Accept-Encoding', '') + .expect(200) + .expect('Cache-Control', 'public, max-age=0') + .expect('Content-Type', /javascript/) + .expect('Content-Length', String(index.length)) + .expect('Vary', 'Accept-Encoding') + .expect(index.toString()) + .end(function (err, res) { + if (err) + return done(err) + res.should.not.have.header('Content-Encoding') + res.should.have.header('Content-Length') + res.should.have.header('Last-Modified') + res.should.have.header('ETag') + done() + }) + }) + + it('should serve files with brotli stream', function (done) { + var index = fs.readFileSync('index.js') + zlib.brotliCompress(index, function (err, content) { + request(server7) + .get('/index.js') + .set('Accept-Encoding', 'br') + .expect(200) + .expect('Cache-Control', 'public, max-age=0') + .expect('Content-Encoding', 'br') + .expect('Content-Type', /javascript/) + .expect('Vary', 'Accept-Encoding') + .expect(function (res) { + return res.body.toString('hex') === content.toString('hex') + }) + .end(function (err, res) { + if (err) + return done(err) + res.should.not.have.header('Content-Length') + res.should.have.header('Last-Modified') + res.should.have.header('ETag') + + etag = res.headers.etag + + done() + }) + }) + }) })