diff --git a/.eslintrc b/.eslintrc index 6103440..47cce25 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,5 +1,5 @@ { - "extends": "google", + "extends": "r0mflip", "env": { "node": true }, @@ -7,29 +7,11 @@ "ecmaVersion": 2017 }, "rules": { - "max-len": [2, 100, { - "ignoreComments": true, + "max-len": [2, { + "code": 100, + "tabWidth": 2, "ignoreUrls": true, - "tabWidth": 2 - }], - "no-implicit-coercion": [2, { - "boolean": false, - "number": true, - "string": true - }], - "no-unused-expressions": [2, { - "allowShortCircuit": true, - "allowTernary": false - }], - "no-unused-vars": [2, { - "vars": "all", - "args": "after-used", - "argsIgnorePattern": "(^reject$|^_$)", - "varsIgnorePattern": "(^_$)" - }], - "quotes": [2, "single"], - "require-jsdoc": 0, - "valid-jsdoc": 0, - "arrow-parens": 0 + "ignoreComments": true + }] } } diff --git a/.travis.yml b/.travis.yml index f540949..2307c7c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,11 @@ dist: trusty language: node_js +sudo: false node_js: - - "9" + - "8" + - "10" script: - npm run test +notification: + on_success: never + on_failure: always diff --git a/README.md b/README.md index 01cdf05..2e9164d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # taggart -[![Greenkeeper badge](https://badges.greenkeeper.io/ramlmn/taggart.svg)](https://greenkeeper.io/) - An ETag header middleware for express like apps. It is a zero dependency middleware which generates `ETags` accurately. +**Note:** `express.static` uses `serve-static` which internally uses `send`. +`send` generates/supports only weak `ETags`. If you want strong etags, then you +have to write your own cache invalidation in your server, regardless of strong +or weak. + ## Installation ``` bash @@ -27,10 +30,10 @@ app.use(...); # Working -`taggart` simply overrides the `write`, `end` and `send` methods of the `res` +`taggart` simply overrides the `write` and `end` methods of the `res` (`ServerResponse` object) and tries to gather around the response content being sent to the client, at the end, `taggart` adds the `ETag` header to the -response if it is't buffered or tansfered in chunks. +response if it is't buffered (or tansfered in chunks). ## License [MIT](LICENSE) diff --git a/index.js b/index.js index 394f7b8..a1d10f3 100644 --- a/index.js +++ b/index.js @@ -2,55 +2,92 @@ const crypto = require('crypto'); +const noop = _ => {}; + /** * An ETag header middleware for express like apps * - * @param {Object} req http IncomingMessage - * @param {Object} res http ServerReponse - * @param {Function} next next middleware to call + * @param {Object} opts options + * @param {Boolean} opts.weak weak tags? + * @return {Function} */ -const addEtag = (req, res, next = _ => {}) => { - const write = res.write; - const end = res.end; - const send = res.send; - - const onData = (...args) => { - if (!res.headersSent - && !res.getHeader('Transfer-Encoding') - && !res.getHeader('TE') - && res.getHeader('Content-Length')) { - // we are in luck - const body = args[0]; - - // @TODO: allow weak etags - - // genarate the etag - const etag = crypto.createHash('sha1') - .update(body) - .digest('hex'); - - res.setHeader('ETag', etag); - } - }; +const addEtag = opts => { + const weak = (opts !== undefined) ? !!opts.weak : false; - // override the default methods and collect response buffer + return (req, res, next = noop) => { + const write = res.write; + const end = res.end; - res.write = (...args) => { - onData(...args); - write.apply(res, [...args]); - }; + // sha1 ain't that bad + const hash = crypto.createHash('sha1'); - res.end = (...args) => { - onData(...args); - end.apply(res, [...args]); - }; + // keep track, for content-length + let length = 0; - res.send = (...args) => { - onData(...args); - send.apply(res, [...args]); - }; + let onData = (chunk, encoding) => { + const TE = (res.getHeader('Transfer-Encoding') || res.getHeader('TE') || '').toLowerCase(); + + // if (chunk) { + // console.log(Buffer.byteLength(chunk, 'utf8'), res.headersSent, TE); + // } else { + // console.log(undefined, res.headersSent, TE); + // } + + if (res.headersSent || TE === 'chunked') { + // bail + onData = noop; + return; + } + + // convert chunk to buffer + chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding); + + hash.update(chunk); + length += Buffer.byteLength(chunk, 'utf8'); + }; + + const onEnd = (chunk, encoding) => { + onData(chunk, encoding); - next(); + // generate tag + const l = length.toString(16); + const h = hash.digest('hex'); + + const tag = weak ? `W/${l}-${h}` : `${l}-${h}`; + + // check if headers can be sent, ignore TE + if (!res.headersSent) { + res.setHeader('Content-Length', length); + + if (res.getHeader('ETag')) { + res.removeHeader('ETag'); + } + + res.setHeader('ETag', tag); + } + }; + + // override the default methods + res.write = (...args) => { + onData(...args); + write.apply(res, [...args]); + }; + + res.end = (...args) => { + onEnd(...args); + end.apply(res, [...args]); + }; + + // non standard, express like + res.send = (...args) => { + onEnd(...args); + end.apply(res, [...args]); + }; + + if (typeof next === 'function') { + next(); + } + }; }; diff --git a/package.json b/package.json index 361f4b4..d5d1d06 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,37 @@ { "name": "@ramlmn/taggart", "version": "0.1.0", + "author": "@r0mflip", "description": "An ETag header middleware for express like apps", "main": "index.js", "scripts": { "test": "eslint . && node test/test.js" }, + "engines": { + "node": ">=8.0.0" + }, + "homepage": "https://github.com/ramlmn/taggart#readme", + "bugs": { + "url": "https://github.com/ramlmn/taggart/issues" + }, "repository": { "type": "git", "url": "git+https://github.com/ramlmn/taggart.git" }, + "license": "MIT", + "devDependencies": { + "eslint": "^5.6.0", + "eslint-config-r0mflip": "^0.1.3", + "express": "^4.16.3", + "node-fetch": "^2.2.0", + "tap-spec": "^5.0.0", + "tape": "^4.9.1" + }, "keywords": [ "ETag", "e-tag", "headers", "express", "middleware" - ], - "author": "@mram", - "license": "MIT", - "bugs": { - "url": "https://github.com/ramlmn/taggart/issues" - }, - "homepage": "https://github.com/ramlmn/taggart#readme", - "devDependencies": { - "eslint": "^5.2.0", - "eslint-config-google": "^0.9.1", - "express": "^4.16.3", - "node-fetch": "^2.1.2", - "tap-spec": "^5.0.0", - "tape": "^4.9.0" - } + ] } diff --git a/test/static/README.md b/test/static/README.md new file mode 100644 index 0000000..d1acfa9 --- /dev/null +++ b/test/static/README.md @@ -0,0 +1,5 @@ +# Attributions + + * [atari-adventure.jpg](atari-adventure.jpg) by Scott Canoni from [WikiMedia Commons](https://commons.wikimedia.org/wiki/File:Atari_Adventure_Easter_Egg_on_Atari_(Jakks_Pacific)_Port.jpg) + * Big buck bunny [trailer](trailer_400p.ogg) from [https://peach.blender.org/trailer-page/](https://peach.blender.org/trailer-page/) + * Lipsum text from [https://lipsum.com](https://lipsum.com) diff --git a/test/static/atari-adventure.jpg b/test/static/atari-adventure.jpg new file mode 100644 index 0000000..35cc0a3 Binary files /dev/null and b/test/static/atari-adventure.jpg differ diff --git a/test/public/garble.txt b/test/static/large-text.txt similarity index 100% rename from test/public/garble.txt rename to test/static/large-text.txt diff --git a/test/public/sample.json b/test/static/sample.json similarity index 100% rename from test/public/sample.json rename to test/static/sample.json diff --git a/test/static/small-text.txt b/test/static/small-text.txt new file mode 100644 index 0000000..a5f6c2f --- /dev/null +++ b/test/static/small-text.txt @@ -0,0 +1 @@ +values of β give rises to dom! diff --git a/test/static/trailer_400p.ogg b/test/static/trailer_400p.ogg new file mode 100644 index 0000000..bced4b8 Binary files /dev/null and b/test/static/trailer_400p.ogg differ diff --git a/test/test.js b/test/test.js index 1b60f4c..6d7bfa6 100644 --- a/test/test.js +++ b/test/test.js @@ -14,29 +14,44 @@ tape .pipe(tapSpec()) .pipe(process.stdout); -tape('ETag test', async t => { - t.plan(2); + +tape('ETags test in express', async t => { + t.plan(5); const app = express(); app.set('etag', false); - app.use(taggart); - app.use(express.static(path.resolve(__dirname, 'public'))); + app.use(taggart()); + app.use(express.static(path.resolve(__dirname, 'static'))); const server = app.listen(0, async _ => { const {port} = server.address(); try { - const res = await fetch(`http://localhost:${port}/garble.txt`); + const res = await fetch(`http://localhost:${port}/large-text.txt`); + const resETag = res.headers.get('ETag'); + const testTag = 'W/"6281-165de617a21"'; + + if (resETag && testTag === resETag.toLowerCase()) { + t.pass(`Large text - got valid ETag ${resETag}`); + } else { + t.fail(`Large text - got invalid ETag ${resETag}`); + } + } catch (e) { + t.fail('Large text - Failed: ', e); + } + + try { + const res = await fetch(`http://localhost:${port}/small-text.txt`); const resETag = res.headers.get('ETag'); const testTag = '691544a391db46480b9a425ae3126fe2a0ec22fa'; if (resETag && testTag === resETag.toLowerCase()) { - t.pass(`Test 1: Got valid ETag: ${resETag}`); + t.pass(`Small text - got valid ETag ${resETag}`); } else { - t.fail(`Test 1: Got invalid ETag: ${resETag}`); + t.fail(`Small text - got invalid ETag ${resETag}`); } } catch (e) { - t.fail('Test 1: Failed: ', e); + t.fail('Small text - Failed: ', e); } try { @@ -45,12 +60,40 @@ tape('ETag test', async t => { const testTag = 'b13764e8ffd613c4734f67fe51e47afd6bd903f2'; if (resETag && testTag === resETag.toLowerCase()) { - t.pass(`Test 2: Got valid ETag: ${resETag}`); + t.pass(`JSON request - got valid ETag ${resETag}`); + } else { + t.fail(`JSON request - got invalid ETag ${resETag}`); + } + } catch (e) { + t.fail('JSON request: Failed: ', e); + } + + try { + const res = await fetch(`http://localhost:${port}/trailer_400p.ogg`); + const resETag = res.headers.get('ETag'); + const testTag = 'b13764e8ffd613c4734f67fe51e47afd6bd903f2'; + + if (resETag && testTag === resETag.toLowerCase()) { + t.pass(`Video request - got valid ETag ${resETag}`); + } else { + t.fail(`Video request - got invalid ETag ${resETag}`); + } + } catch (e) { + t.fail('Video request: Failed: ', e); + } + + try { + const res = await fetch(`http://localhost:${port}/atari-adventure.jpg`); + const resETag = res.headers.get('ETag'); + const testTag = 'b13764e8ffd613c4734f67fe51e47afd6bd903f2'; + + if (resETag && testTag === resETag.toLowerCase()) { + t.pass(`Video request - got valid ETag ${resETag}`); } else { - t.fail(`Test 2: Got invalid ETag: ${resETag}`); + t.fail(`Video request - got invalid ETag ${resETag}`); } } catch (e) { - t.fail('Test 2: Failed: ', e); + t.fail('Video request: Failed: ', e); } server.close();