From e7c4e42a63e9cd59b43256bb4ca04a22a5f77994 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 9 Jul 2019 17:27:11 -0700 Subject: [PATCH] Add "etag" response header config option (#94) * Add "etag" response header Utilize the `etag` module on npm to send an "etag" response header. * Revert "Add "etag" response header" This reverts commit 3c208191a314855a6f79b8f942cb0563e4410032. * Update README with `etag` option * Implement sha1 etags * Remove extra newline * Strong ETag and always send Last-Modified * Cast mtime to number * Make Last-Modified be either/or again * Document that it's a strong etag --- README.md | 41 +++++++++++++++++++++++++++++------------ src/index.js | 36 +++++++++++++++++++++++++++++++----- test/integration.js | 15 ++++++++++++++- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f2c09b8..fd6f5ab 100644 --- a/README.md +++ b/README.md @@ -47,18 +47,19 @@ await handler(request, response, { You can use any of the following options: -| Property | Description | -|------------------------------------------------------|-----------------------------------------------------------| -| [`public`](#public-string) | Set a sub directory to be served | -| [`cleanUrls`](#cleanurls-booleanarray) | Have the `.html` extension stripped from paths | -| [`rewrites`](#rewrites-array) | Rewrite paths to different paths | -| [`redirects`](#redirects-array) | Forward paths to different paths or external URLs | -| [`headers`](#headers-array) | Set custom headers for specific paths | -| [`directoryListing`](#directorylisting-booleanarray) | Disable directory listing or restrict it to certain paths | -| [`unlisted`](#unlisted-array) | Exclude paths from the directory listing | -| [`trailingSlash`](#trailingslash-boolean) | Remove or add trailing slashes to all paths | -| [`renderSingle`](#rendersingle-boolean) | If a directory only contains one file, render it | -| [`symlinks`](#symlinks-boolean) | Resolve symlinks instead of rendering a 404 error | +| Property | Description | +|------------------------------------------------------|-----------------------------------------------------------------------| +| [`public`](#public-string) | Set a sub directory to be served | +| [`cleanUrls`](#cleanurls-booleanarray) | Have the `.html` extension stripped from paths | +| [`rewrites`](#rewrites-array) | Rewrite paths to different paths | +| [`redirects`](#redirects-array) | Forward paths to different paths or external URLs | +| [`headers`](#headers-array) | Set custom headers for specific paths | +| [`directoryListing`](#directorylisting-booleanarray) | Disable directory listing or restrict it to certain paths | +| [`unlisted`](#unlisted-array) | Exclude paths from the directory listing | +| [`trailingSlash`](#trailingslash-boolean) | Remove or add trailing slashes to all paths | +| [`renderSingle`](#rendersingle-boolean) | If a directory only contains one file, render it | +| [`symlinks`](#symlinks-boolean) | Resolve symlinks instead of rendering a 404 error | +| [`etag`](#etag-boolean) | Calculate a strong `ETag` response header, instead of `Last-Modified` | ### public (String) @@ -274,6 +275,18 @@ However, this behavior can easily be adjusted: Once this property is set as shown above, all symlinks will automatically be resolved to their targets. +### etag (Boolean) + +HTTP response headers will contain a strong [`ETag`][etag] response header, instead of a [`Last-Modified`][last-modified] header. Opt-in because calculating the hash value may be computationally expensive for large files. + +Sending an `ETag` header is disabled by default and can be enabled like this: + +```js +{ + "etag": true +} +``` + ## Error templates The handler will automatically determine the right error format if one occurs and then sends it to the client in that format. @@ -317,3 +330,7 @@ Since it comes with support for `serve-handler` out of the box, you can create a ## Author Leo Lamprecht ([@notquiteleo](https://twitter.com/notquiteleo)) - [ZEIT](https://zeit.co) + + +[etag]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag +[last-modified]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified diff --git a/src/index.js b/src/index.js index e0a74e6..9a8a824 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ // Native const {promisify} = require('util'); const path = require('path'); +const {createHash} = require('crypto'); const {realpath, lstat, createReadStream, readdir} = require('fs'); // Packages @@ -18,6 +19,20 @@ const parseRange = require('range-parser'); const directoryTemplate = require('./directory'); const errorTemplate = require('./error'); +const etags = new Map(); + +const calculateSha = (handlers, absolutePath) => + new Promise((resolve, reject) => { + const hash = createHash('sha1'); + const rs = handlers.createReadStream(absolutePath); + rs.on('error', reject); + rs.on('data', buf => hash.update(buf)); + rs.on('end', () => { + const sha = hash.digest('hex'); + resolve(sha); + }); + }); + const sourceMatches = (source, requestPath, allowSegments) => { const keys = []; const slashed = slasher(source); @@ -177,7 +192,8 @@ const appendHeaders = (target, source) => { } }; -const getHeaders = async (customHeaders = [], current, absolutePath, stats) => { +const getHeaders = async (handlers, config, current, absolutePath, stats) => { + const {headers: customHeaders = [], etag = false} = config; const related = {}; const {base} = path.parse(absolutePath); const relativePath = path.relative(current, absolutePath); @@ -199,7 +215,6 @@ const getHeaders = async (customHeaders = [], current, absolutePath, stats) => { if (stats) { defaultHeaders = { - 'Last-Modified': stats.mtime.toUTCString(), 'Content-Length': stats.size, // Default to "inline", which always tries to render in the browser, // if that's not working, it will save the file. But to be clear: This @@ -210,6 +225,17 @@ const getHeaders = async (customHeaders = [], current, absolutePath, stats) => { 'Accept-Ranges': 'bytes' }; + if (etag) { + let [mtime, sha] = etags.get(absolutePath) || []; + if (Number(mtime) !== Number(stats.mtime)) { + sha = await calculateSha(handlers, absolutePath); + etags.set(absolutePath, [stats.mtime, sha]); + } + defaultHeaders['ETag'] = `"${sha}"`; + } else { + defaultHeaders['Last-Modified'] = stats.mtime.toUTCString(); + } + const contentType = mime.contentType(base); if (contentType) { @@ -479,7 +505,7 @@ const sendError = async (absolutePath, response, acceptsJSON, current, handlers, try { stream = await handlers.createReadStream(errorPage); - const headers = await getHeaders(config.headers, current, errorPage, stats); + const headers = await getHeaders(handlers, config, current, errorPage, stats); response.writeHead(statusCode, headers); stream.pipe(response); @@ -490,7 +516,7 @@ const sendError = async (absolutePath, response, acceptsJSON, current, handlers, } } - const headers = await getHeaders(config.headers, current, absolutePath, null); + const headers = await getHeaders(handlers, config, current, absolutePath, null); headers['Content-Type'] = 'text/html; charset=utf-8'; response.writeHead(statusCode, headers); @@ -704,7 +730,7 @@ module.exports = async (request, response, config = {}, methods = {}) => { return internalError(absolutePath, response, acceptsJSON, current, handlers, config, err); } - const headers = await getHeaders(config.headers, current, absolutePath, stats); + const headers = await getHeaders(handlers, config, current, absolutePath, stats); // eslint-disable-next-line no-undefined if (streamOpts.start !== undefined && streamOpts.end !== undefined) { diff --git a/test/integration.js b/test/integration.js index 87d8736..756537b 100644 --- a/test/integration.js +++ b/test/integration.js @@ -1079,7 +1079,7 @@ test('automatically handle ETag headers for normal files', async t => { const name = 'object.json'; const related = path.join(fixturesFull, name); const content = await fs.readJSON(related); - const value = 'd2ijdjoi29f3h3232'; + const value = '"d2ijdjoi29f3h3232"'; const url = await getUrl({ headers: [{ @@ -1329,3 +1329,16 @@ test('allow symlinks by setting the option', async t => { t.is(text, spec); }); + +test('etag header is set', async t => { + const directory = 'single-directory'; + const url = await getUrl({ + renderSingle: true, + etag: true + }); + const response = await fetch(`${url}/${directory}`); + t.is( + response.headers.get('etag'), + '"4e5f19df3bfe8db7d588edfc3960991aa0715ccf"' + ); +});