diff --git a/.eslintrc b/.eslintrc index 91288aa..4ab2c05 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,4 +1,4 @@ { "extends": "hexo", "root": true -} \ No newline at end of file +} diff --git a/README.md b/README.md index eeeec90..8603511 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,7 @@ server: cache: false header: true serveStatic: - extensions: - - html + pre_compressed: false ``` - **port**: Server port @@ -51,6 +50,7 @@ server: - Suitable for production environment only. - **header**: Add `X-Powered-By: Hexo` header - **serveStatic**: Extra options passed to [serve-static](https://github.com/expressjs/serve-static#options) +- **pre_compressed**: Serve pre-compressed assets, requires a [minifier plugin](https://hexo.io/plugins/) that supports gzip or brotli ## License diff --git a/index.js b/index.js index 9f366b4..abf8d4a 100644 --- a/index.js +++ b/index.js @@ -8,7 +8,8 @@ hexo.config.server = Object.assign({ // `undefined` uses Node's default (try `::` with fallback to `0.0.0.0`) ip: undefined, compress: false, - header: true + header: true, + pre_compressed: false }, hexo.config.server); hexo.extend.console.register('server', 'Start the server.', { @@ -23,6 +24,7 @@ hexo.extend.console.register('server', 'Start the server.', { }, require('./lib/server')); hexo.extend.filter.register('server_middleware', require('./lib/middlewares/header')); +hexo.extend.filter.register('server_middleware', require('./lib/middlewares/pre_compressed')); hexo.extend.filter.register('server_middleware', require('./lib/middlewares/gzip')); hexo.extend.filter.register('server_middleware', require('./lib/middlewares/logger')); hexo.extend.filter.register('server_middleware', require('./lib/middlewares/route')); diff --git a/lib/middlewares/pre_compressed.js b/lib/middlewares/pre_compressed.js new file mode 100644 index 0000000..3a3ea34 --- /dev/null +++ b/lib/middlewares/pre_compressed.js @@ -0,0 +1,60 @@ +'use strict'; + +const { getType } = require('mime'); + +module.exports = function(app) { + const { config, route } = this; + const { root } = config; + const { pretty_urls } = config; + const { trailing_index, trailing_html } = pretty_urls ? pretty_urls : {}; + + if (!config.server.pre_compressed) return; + + app.use(root, (req, res, next) => { + const { headers, method, url: requestUrl } = req; + const acceptEncoding = headers['accept-encoding'] || ''; + const vary = res.getHeader('Vary'); + const url = route.format(decodeURIComponent(requestUrl)); + const data = route.get(url); + + if (method !== 'GET' && method !== 'HEAD') return next(); + + const preFn = (acceptEncoding, url, req, res) => { + res.setHeader('Content-Type', getType(url) + '; charset=utf-8'); + + if (acceptEncoding.includes('br') && route.get(url + '.br')) { + req.url = encodeURI('/' + url + '.br'); + res.setHeader('Content-Encoding', 'br'); + } else if (acceptEncoding.includes('gzip') && route.get(url + '.gz')) { + req.url = encodeURI('/' + url + '.gz'); + res.setHeader('Content-Encoding', 'gzip'); + } + }; + + if (data) { + if ((trailing_html === false && !requestUrl.endsWith('/index.html') && requestUrl.endsWith('.html')) || (trailing_index === false && requestUrl.endsWith('/index.html'))) { + // location `foo/bar.html`; request `foo/bar.html`; redirect to `foo/bar` + // location `foo/index.html`; request `foo/index.html`; redirect to `foo/` + return next(); + } + // location `foo/bar/index.html`; request `foo/bar/` or `foo/bar/index.html; proxy to the location + // also applies to non-html + preFn(acceptEncoding, url, req, res); + } else { + if (route.get(url + '.html')) { + // location `foo/bar.html`; request `foo/bar`; proxy to the `foo/bar.html.br` + preFn(acceptEncoding, url + '.html', req, res); + } else { + return next(); + } + } + + if (!vary) { + res.setHeader('Vary', 'Accept-Encoding'); + } else if (!vary.includes('Accept-Encoding')) { + res.setHeader('Vary', vary + ', Accept-Encoding'); + } + + return next(); + }); +}; diff --git a/lib/middlewares/route.js b/lib/middlewares/route.js index b3f9d2d..28a823d 100644 --- a/lib/middlewares/route.js +++ b/lib/middlewares/route.js @@ -6,7 +6,8 @@ const mime = require('mime'); module.exports = function(app) { const { config, route } = this; const { args = {} } = this.env; - const { root } = config; + const { root, server } = config; + const preCompressed = server.pre_compressed ? server.pre_compressed : false; if (args.s || args.static) return; @@ -63,7 +64,9 @@ module.exports = function(app) { return; } - res.setHeader('Content-Type', extname ? mime.getType(extname) : 'application/octet-stream'); + if (!preCompressed || (!req.url.endsWith('.br') && !req.url.endsWith('.gz'))) { + res.setHeader('Content-Type', extname ? mime.getType(extname) : 'application/octet-stream'); + } if (method === 'GET') { data.pipe(res).on('error', next); diff --git a/package.json b/package.json index 98e00ff..d7fd188 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "chai": "^4.2.0", "chai-as-promised": "^7.1.1", "eslint": "^7.0.0", - "eslint-config-hexo": "^4.0.0", + "eslint-config-hexo": "^4.1.0", "hexo": "^5.0.0", "hexo-fs": "^3.0.1", "hexo-util": "^2.1.0", diff --git a/test/index.js b/test/index.js index 34b99ba..0c6be24 100644 --- a/test/index.js +++ b/test/index.js @@ -28,14 +28,24 @@ describe('server', () => { // Register fake generator hexo.extend.generator.register('test', () => [ + {path: '', data: 'index'}, {path: 'index.html', data: 'index'}, {path: 'foo/index.html', data: 'foo'}, + {path: 'foo/index.html.gz', data: 'foo'}, {path: 'bar/baz.html', data: 'baz'}, - {path: 'bar.jpg', data: 'bar'} + {path: 'bar/baz.html.gz', data: 'baz'}, + {path: 'bar.jpg', data: 'bar'}, + {path: 'foo/', data: 'foo'}, + {path: 'foo bar.js', data: 'file'}, + {path: 'foo bar.js.gz', data: ''}, + {path: 'foo bar.js.br', data: ''}, + {path: 'foo/index.html.br', data: ''}, + {path: 'foo/index.html.gz', data: ''} ]); // Register middlewares hexo.extend.filter.register('server_middleware', require('../lib/middlewares/header')); + hexo.extend.filter.register('server_middleware', require('../lib/middlewares/pre_compressed')); hexo.extend.filter.register('server_middleware', require('../lib/middlewares/gzip')); hexo.extend.filter.register('server_middleware', require('../lib/middlewares/logger')); hexo.extend.filter.register('server_middleware', require('../lib/middlewares/route')); @@ -102,6 +112,74 @@ describe('server', () => { .expect('Content-Type', 'image/jpeg') .expect(200))); + describe('options.pre_compressed', () => { + beforeEach(() => { hexo.config.server.pre_compressed = false; }); + + it('Serve brotli (br) if supported', async () => { + hexo.config.server.pre_compressed = true; + + await Promise.using( + prepareServer(), + app => request(app) + .get('/foo%20bar.js') + .set('Accept-Encoding', 'gzip, br') + .expect('Content-Encoding', 'br') + .expect('Content-Type', 'application/javascript; charset=utf-8') + ); + }); + + it('Serve gzip if br is not supported', async () => { + hexo.config.server.pre_compressed = true; + + return Promise.using( + prepareServer(), + app => request(app) + .get('/foo%20bar.js') + .set('Accept-Encoding', 'gzip') + .expect('Content-Encoding', 'gzip') + .expect('Content-Type', 'application/javascript; charset=utf-8') + ); + }); + + it('Disable', async () => { + hexo.config.server.pre_compressed = false; + + await Promise.using( + prepareServer(), + app => request(app) + .get('/foo%20bar.js') + .set('Accept-Encoding', 'gzip, br') + .then(res => { + res.headers.should.not.have.property('Content-Encoding'); + }) + ); + }); + + it('route / to /index.html.br', async () => { + hexo.config.server.pre_compressed = true; + + await Promise.using( + prepareServer(), + app => request(app) + .get('/foo/') + .set('Accept-Encoding', 'gzip, br') + .expect('Content-Encoding', 'br') + ); + }); + + it('route / to /index.html.gz', async () => { + hexo.config.server.pre_compressed = true; + + await Promise.using( + prepareServer(), + app => request(app) + .get('/foo/') + .set('Accept-Encoding', 'gzip') + .expect('Content-Encoding', 'gzip') + ); + }); + }); + it('Enable compression if options.compress is true', () => { hexo.config.server.compress = true;