Skip to content

Commit e7c4e42

Browse files
authored
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 3c20819. * 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
1 parent 3634a94 commit e7c4e42

3 files changed

Lines changed: 74 additions & 18 deletions

File tree

README.md

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,19 @@ await handler(request, response, {
4747

4848
You can use any of the following options:
4949

50-
| Property | Description |
51-
|------------------------------------------------------|-----------------------------------------------------------|
52-
| [`public`](#public-string) | Set a sub directory to be served |
53-
| [`cleanUrls`](#cleanurls-booleanarray) | Have the `.html` extension stripped from paths |
54-
| [`rewrites`](#rewrites-array) | Rewrite paths to different paths |
55-
| [`redirects`](#redirects-array) | Forward paths to different paths or external URLs |
56-
| [`headers`](#headers-array) | Set custom headers for specific paths |
57-
| [`directoryListing`](#directorylisting-booleanarray) | Disable directory listing or restrict it to certain paths |
58-
| [`unlisted`](#unlisted-array) | Exclude paths from the directory listing |
59-
| [`trailingSlash`](#trailingslash-boolean) | Remove or add trailing slashes to all paths |
60-
| [`renderSingle`](#rendersingle-boolean) | If a directory only contains one file, render it |
61-
| [`symlinks`](#symlinks-boolean) | Resolve symlinks instead of rendering a 404 error |
50+
| Property | Description |
51+
|------------------------------------------------------|-----------------------------------------------------------------------|
52+
| [`public`](#public-string) | Set a sub directory to be served |
53+
| [`cleanUrls`](#cleanurls-booleanarray) | Have the `.html` extension stripped from paths |
54+
| [`rewrites`](#rewrites-array) | Rewrite paths to different paths |
55+
| [`redirects`](#redirects-array) | Forward paths to different paths or external URLs |
56+
| [`headers`](#headers-array) | Set custom headers for specific paths |
57+
| [`directoryListing`](#directorylisting-booleanarray) | Disable directory listing or restrict it to certain paths |
58+
| [`unlisted`](#unlisted-array) | Exclude paths from the directory listing |
59+
| [`trailingSlash`](#trailingslash-boolean) | Remove or add trailing slashes to all paths |
60+
| [`renderSingle`](#rendersingle-boolean) | If a directory only contains one file, render it |
61+
| [`symlinks`](#symlinks-boolean) | Resolve symlinks instead of rendering a 404 error |
62+
| [`etag`](#etag-boolean) | Calculate a strong `ETag` response header, instead of `Last-Modified` |
6263

6364
### public (String)
6465

@@ -274,6 +275,18 @@ However, this behavior can easily be adjusted:
274275

275276
Once this property is set as shown above, all symlinks will automatically be resolved to their targets.
276277

278+
### etag (Boolean)
279+
280+
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.
281+
282+
Sending an `ETag` header is disabled by default and can be enabled like this:
283+
284+
```js
285+
{
286+
"etag": true
287+
}
288+
```
289+
277290
## Error templates
278291

279292
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
317330
## Author
318331

319332
Leo Lamprecht ([@notquiteleo](https://twitter.com/notquiteleo)) - [ZEIT](https://zeit.co)
333+
334+
335+
[etag]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
336+
[last-modified]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified

src/index.js

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Native
22
const {promisify} = require('util');
33
const path = require('path');
4+
const {createHash} = require('crypto');
45
const {realpath, lstat, createReadStream, readdir} = require('fs');
56

67
// Packages
@@ -18,6 +19,20 @@ const parseRange = require('range-parser');
1819
const directoryTemplate = require('./directory');
1920
const errorTemplate = require('./error');
2021

22+
const etags = new Map();
23+
24+
const calculateSha = (handlers, absolutePath) =>
25+
new Promise((resolve, reject) => {
26+
const hash = createHash('sha1');
27+
const rs = handlers.createReadStream(absolutePath);
28+
rs.on('error', reject);
29+
rs.on('data', buf => hash.update(buf));
30+
rs.on('end', () => {
31+
const sha = hash.digest('hex');
32+
resolve(sha);
33+
});
34+
});
35+
2136
const sourceMatches = (source, requestPath, allowSegments) => {
2237
const keys = [];
2338
const slashed = slasher(source);
@@ -177,7 +192,8 @@ const appendHeaders = (target, source) => {
177192
}
178193
};
179194

180-
const getHeaders = async (customHeaders = [], current, absolutePath, stats) => {
195+
const getHeaders = async (handlers, config, current, absolutePath, stats) => {
196+
const {headers: customHeaders = [], etag = false} = config;
181197
const related = {};
182198
const {base} = path.parse(absolutePath);
183199
const relativePath = path.relative(current, absolutePath);
@@ -199,7 +215,6 @@ const getHeaders = async (customHeaders = [], current, absolutePath, stats) => {
199215

200216
if (stats) {
201217
defaultHeaders = {
202-
'Last-Modified': stats.mtime.toUTCString(),
203218
'Content-Length': stats.size,
204219
// Default to "inline", which always tries to render in the browser,
205220
// 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) => {
210225
'Accept-Ranges': 'bytes'
211226
};
212227

228+
if (etag) {
229+
let [mtime, sha] = etags.get(absolutePath) || [];
230+
if (Number(mtime) !== Number(stats.mtime)) {
231+
sha = await calculateSha(handlers, absolutePath);
232+
etags.set(absolutePath, [stats.mtime, sha]);
233+
}
234+
defaultHeaders['ETag'] = `"${sha}"`;
235+
} else {
236+
defaultHeaders['Last-Modified'] = stats.mtime.toUTCString();
237+
}
238+
213239
const contentType = mime.contentType(base);
214240

215241
if (contentType) {
@@ -479,7 +505,7 @@ const sendError = async (absolutePath, response, acceptsJSON, current, handlers,
479505
try {
480506
stream = await handlers.createReadStream(errorPage);
481507

482-
const headers = await getHeaders(config.headers, current, errorPage, stats);
508+
const headers = await getHeaders(handlers, config, current, errorPage, stats);
483509

484510
response.writeHead(statusCode, headers);
485511
stream.pipe(response);
@@ -490,7 +516,7 @@ const sendError = async (absolutePath, response, acceptsJSON, current, handlers,
490516
}
491517
}
492518

493-
const headers = await getHeaders(config.headers, current, absolutePath, null);
519+
const headers = await getHeaders(handlers, config, current, absolutePath, null);
494520
headers['Content-Type'] = 'text/html; charset=utf-8';
495521

496522
response.writeHead(statusCode, headers);
@@ -704,7 +730,7 @@ module.exports = async (request, response, config = {}, methods = {}) => {
704730
return internalError(absolutePath, response, acceptsJSON, current, handlers, config, err);
705731
}
706732

707-
const headers = await getHeaders(config.headers, current, absolutePath, stats);
733+
const headers = await getHeaders(handlers, config, current, absolutePath, stats);
708734

709735
// eslint-disable-next-line no-undefined
710736
if (streamOpts.start !== undefined && streamOpts.end !== undefined) {

test/integration.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1079,7 +1079,7 @@ test('automatically handle ETag headers for normal files', async t => {
10791079
const name = 'object.json';
10801080
const related = path.join(fixturesFull, name);
10811081
const content = await fs.readJSON(related);
1082-
const value = 'd2ijdjoi29f3h3232';
1082+
const value = '"d2ijdjoi29f3h3232"';
10831083

10841084
const url = await getUrl({
10851085
headers: [{
@@ -1329,3 +1329,16 @@ test('allow symlinks by setting the option', async t => {
13291329

13301330
t.is(text, spec);
13311331
});
1332+
1333+
test('etag header is set', async t => {
1334+
const directory = 'single-directory';
1335+
const url = await getUrl({
1336+
renderSingle: true,
1337+
etag: true
1338+
});
1339+
const response = await fetch(`${url}/${directory}`);
1340+
t.is(
1341+
response.headers.get('etag'),
1342+
'"4e5f19df3bfe8db7d588edfc3960991aa0715ccf"'
1343+
);
1344+
});

0 commit comments

Comments
 (0)