From 3c208191a314855a6f79b8f942cb0563e4410032 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Mon, 8 Jul 2019 18:05:14 -0700 Subject: [PATCH 1/9] Add "etag" response header Utilize the `etag` module on npm to send an "etag" response header. --- package.json | 1 + src/index.js | 2 ++ test/integration.js | 9 +++++++++ yarn.lock | 5 +++++ 4 files changed, 17 insertions(+) diff --git a/package.json b/package.json index aeb270c..91a3e28 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "dependencies": { "bytes": "3.0.0", "content-disposition": "0.5.2", + "etag": "1.8.1", "fast-url-parser": "1.1.3", "mime-types": "2.1.18", "minimatch": "3.0.4", diff --git a/src/index.js b/src/index.js index e0a74e6..9d610e4 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ const path = require('path'); const {realpath, lstat, createReadStream, readdir} = require('fs'); // Packages +const etag = require('etag'); const url = require('fast-url-parser'); const slasher = require('./glob-slash'); const minimatch = require('minimatch'); @@ -199,6 +200,7 @@ const getHeaders = async (customHeaders = [], current, absolutePath, stats) => { if (stats) { defaultHeaders = { + 'ETag': etag(stats), 'Last-Modified': stats.mtime.toUTCString(), 'Content-Length': stats.size, // Default to "inline", which always tries to render in the browser, diff --git a/test/integration.js b/test/integration.js index 87d8736..565955f 100644 --- a/test/integration.js +++ b/test/integration.js @@ -1329,3 +1329,12 @@ 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 + }); + const response = await fetch(`${url}/${directory}`); + t.truthy(/^W\/"(.+)"$/.test(response.headers.get('etag'))); +}); diff --git a/yarn.lock b/yarn.lock index 2e66a9b..8e773a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1394,6 +1394,11 @@ esutils@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" +etag@1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + execa@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" From 3e8a1520a0f0a638c9bf03b6c427269755f1d405 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 9 Jul 2019 16:34:46 -0700 Subject: [PATCH 2/9] Revert "Add "etag" response header" This reverts commit 3c208191a314855a6f79b8f942cb0563e4410032. --- package.json | 1 - src/index.js | 2 -- test/integration.js | 9 --------- yarn.lock | 5 ----- 4 files changed, 17 deletions(-) diff --git a/package.json b/package.json index 91a3e28..aeb270c 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,6 @@ "dependencies": { "bytes": "3.0.0", "content-disposition": "0.5.2", - "etag": "1.8.1", "fast-url-parser": "1.1.3", "mime-types": "2.1.18", "minimatch": "3.0.4", diff --git a/src/index.js b/src/index.js index 9d610e4..e0a74e6 100644 --- a/src/index.js +++ b/src/index.js @@ -4,7 +4,6 @@ const path = require('path'); const {realpath, lstat, createReadStream, readdir} = require('fs'); // Packages -const etag = require('etag'); const url = require('fast-url-parser'); const slasher = require('./glob-slash'); const minimatch = require('minimatch'); @@ -200,7 +199,6 @@ const getHeaders = async (customHeaders = [], current, absolutePath, stats) => { if (stats) { defaultHeaders = { - 'ETag': etag(stats), 'Last-Modified': stats.mtime.toUTCString(), 'Content-Length': stats.size, // Default to "inline", which always tries to render in the browser, diff --git a/test/integration.js b/test/integration.js index 565955f..87d8736 100644 --- a/test/integration.js +++ b/test/integration.js @@ -1329,12 +1329,3 @@ 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 - }); - const response = await fetch(`${url}/${directory}`); - t.truthy(/^W\/"(.+)"$/.test(response.headers.get('etag'))); -}); diff --git a/yarn.lock b/yarn.lock index 8e773a1..2e66a9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1394,11 +1394,6 @@ esutils@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" -etag@1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= - execa@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" From d771141a04744e6c8235b6d13a54b070641eb947 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 9 Jul 2019 16:35:52 -0700 Subject: [PATCH 3/9] Update README with `etag` option --- README.md | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f2c09b8..be8cff6 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 an `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 an [`ETag`][etag] response header instead of a [`Last-Modified`][last-modified] header. Opt-in because calculating the hash value may be computationally expensive. + +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 From 66b93fcad79e312606659230346672beb05b7ac9 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 9 Jul 2019 16:57:54 -0700 Subject: [PATCH 4/9] Implement sha1 etags --- src/index.js | 37 ++++++++++++++++++++++++++++++++----- test/integration.js | 15 ++++++++++++++- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/index.js b/src/index.js index e0a74e6..a70f094 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,21 @@ 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 +193,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 +216,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 +226,17 @@ const getHeaders = async (customHeaders = [], current, absolutePath, stats) => { 'Accept-Ranges': 'bytes' }; + if (etag) { + let [mtime, sha] = etags.get(absolutePath) || []; + if (mtime !== stats.mtime) { + sha = await calculateSha(handlers, absolutePath); + etags.set(absolutePath, [stats.mtime, sha]); + } + defaultHeaders['ETag'] = `W/"${sha}"`; + } else { + defaultHeaders['Last-Modified'] = stats.mtime.toUTCString(); + } + const contentType = mime.contentType(base); if (contentType) { @@ -479,7 +506,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 +517,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 +731,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..bdfbfa1 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'), + 'W/"4e5f19df3bfe8db7d588edfc3960991aa0715ccf"' + ); +}); From 433b4898dd5edd81d6237c62f90277e11374274f Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 9 Jul 2019 17:11:47 -0700 Subject: [PATCH 5/9] Remove extra newline --- src/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/index.js b/src/index.js index a70f094..d25638b 100644 --- a/src/index.js +++ b/src/index.js @@ -33,7 +33,6 @@ const calculateSha = (handlers, absolutePath) => }); }); - const sourceMatches = (source, requestPath, allowSegments) => { const keys = []; const slashed = slasher(source); From 47340f9cf2dbb0d75af5dec7239631d8c0a9609b Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 9 Jul 2019 17:15:12 -0700 Subject: [PATCH 6/9] Strong ETag and always send Last-Modified --- README.md | 28 ++++++++++++++-------------- src/index.js | 5 ++--- test/integration.js | 2 +- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index be8cff6..b82bd3c 100644 --- a/README.md +++ b/README.md @@ -47,19 +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 | -| [`etag`](#etag-boolean) | Calculate an `ETag` response header, instead of `Last-Modified` | +| 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 | ### public (String) @@ -277,7 +277,7 @@ Once this property is set as shown above, all symlinks will automatically be res ### etag (Boolean) -HTTP response headers will contain an [`ETag`][etag] response header instead of a [`Last-Modified`][last-modified] header. Opt-in because calculating the hash value may be computationally expensive. +HTTP response headers will contain an [`ETag`][etag] response header, in addition to the [`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: diff --git a/src/index.js b/src/index.js index d25638b..f734533 100644 --- a/src/index.js +++ b/src/index.js @@ -215,6 +215,7 @@ const getHeaders = async (handlers, config, 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 @@ -231,9 +232,7 @@ const getHeaders = async (handlers, config, current, absolutePath, stats) => { sha = await calculateSha(handlers, absolutePath); etags.set(absolutePath, [stats.mtime, sha]); } - defaultHeaders['ETag'] = `W/"${sha}"`; - } else { - defaultHeaders['Last-Modified'] = stats.mtime.toUTCString(); + defaultHeaders['ETag'] = `"${sha}"`; } const contentType = mime.contentType(base); diff --git a/test/integration.js b/test/integration.js index bdfbfa1..756537b 100644 --- a/test/integration.js +++ b/test/integration.js @@ -1339,6 +1339,6 @@ test('etag header is set', async t => { const response = await fetch(`${url}/${directory}`); t.is( response.headers.get('etag'), - 'W/"4e5f19df3bfe8db7d588edfc3960991aa0715ccf"' + '"4e5f19df3bfe8db7d588edfc3960991aa0715ccf"' ); }); From 9b2cd66124de8c46d5ad18bf8fdfa2d8182f2c0f Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 9 Jul 2019 17:18:29 -0700 Subject: [PATCH 7/9] Cast mtime to number --- src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index f734533..8ee7d51 100644 --- a/src/index.js +++ b/src/index.js @@ -228,7 +228,7 @@ const getHeaders = async (handlers, config, current, absolutePath, stats) => { if (etag) { let [mtime, sha] = etags.get(absolutePath) || []; - if (mtime !== stats.mtime) { + if (Number(mtime) !== Number(stats.mtime)) { sha = await calculateSha(handlers, absolutePath); etags.set(absolutePath, [stats.mtime, sha]); } From 5b160482b0ef4a17d94656f5068f94e6bd3c3f2f Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 9 Jul 2019 17:21:44 -0700 Subject: [PATCH 8/9] Make Last-Modified be either/or again --- README.md | 28 ++++++++++++++-------------- src/index.js | 3 ++- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index b82bd3c..19e9c59 100644 --- a/README.md +++ b/README.md @@ -47,19 +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 | -| [`etag`](#etag-boolean) | Calculate a strong `ETag` response header | +| 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 an `ETag` response header, instead of `Last-Modified` | ### public (String) @@ -277,7 +277,7 @@ Once this property is set as shown above, all symlinks will automatically be res ### etag (Boolean) -HTTP response headers will contain an [`ETag`][etag] response header, in addition to the [`Last-Modified`][last-modified] header. Opt-in because calculating the hash value may be computationally expensive for large files. +HTTP response headers will contain an [`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: diff --git a/src/index.js b/src/index.js index 8ee7d51..9a8a824 100644 --- a/src/index.js +++ b/src/index.js @@ -215,7 +215,6 @@ const getHeaders = async (handlers, config, 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 @@ -233,6 +232,8 @@ const getHeaders = async (handlers, config, current, absolutePath, stats) => { etags.set(absolutePath, [stats.mtime, sha]); } defaultHeaders['ETag'] = `"${sha}"`; + } else { + defaultHeaders['Last-Modified'] = stats.mtime.toUTCString(); } const contentType = mime.contentType(base); From 1e2bdfeaa6ed22add9998cfb99a8e8ed53767469 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 9 Jul 2019 17:23:11 -0700 Subject: [PATCH 9/9] Document that it's a strong etag --- README.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 19e9c59..fd6f5ab 100644 --- a/README.md +++ b/README.md @@ -47,19 +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 | -| [`etag`](#etag-boolean) | Calculate an `ETag` response header, instead of `Last-Modified` | +| 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) @@ -277,7 +277,7 @@ Once this property is set as shown above, all symlinks will automatically be res ### etag (Boolean) -HTTP response headers will contain an [`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. +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: