Skip to content

Commit

Permalink
Add "etag" response header config option (#94)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
TooTallNate authored Jul 10, 2019
1 parent 3634a94 commit e7c4e42
Show file tree
Hide file tree
Showing 3 changed files with 74 additions and 18 deletions.
41 changes: 29 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
36 changes: 31 additions & 5 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion test/integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [{
Expand Down Expand Up @@ -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"'
);
});

0 comments on commit e7c4e42

Please sign in to comment.