Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"'
);
});