Skip to content

Commit b759181

Browse files
feat: etag support (#1797)
1 parent f69b638 commit b759181

16 files changed

+567
-753
lines changed

Diff for: .cspell.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
"mycustom",
2020
"commitlint",
2121
"nosniff",
22-
"deoptimize"
22+
"deoptimize",
23+
"etag",
24+
"cachable"
2325
],
2426
"ignorePaths": [
2527
"CHANGELOG.md",

Diff for: README.md

+21-13
Original file line numberDiff line numberDiff line change
@@ -60,19 +60,20 @@ See [below](#other-servers) for an example of use with fastify.
6060

6161
## Options
6262

63-
| Name | Type | Default | Description |
64-
| :---------------------------------------------: | :-----------------------: | :-------------------------------------------: | :------------------------------------------------------------------------------------------------------------------- |
65-
| **[`methods`](#methods)** | `Array` | `[ 'GET', 'HEAD' ]` | Allows to pass the list of HTTP request methods accepted by the middleware |
66-
| **[`headers`](#headers)** | `Array\|Object\|Function` | `undefined` | Allows to pass custom HTTP headers on each request. |
67-
| **[`index`](#index)** | `Boolean\|String` | `index.html` | If `false` (but not `undefined`), the server will not respond to requests to the root URL. |
68-
| **[`mimeTypes`](#mimetypes)** | `Object` | `undefined` | Allows to register custom mime types or extension mappings. |
69-
| **[`mimeTypeDefault`](#mimetypedefault)** | `String` | `undefined` | Allows to register a default mime type when we can't determine the content type. |
70-
| **[`publicPath`](#publicpath)** | `String` | `output.publicPath` (from a configuration) | The public path that the middleware is bound to. |
71-
| **[`stats`](#stats)** | `Boolean\|String\|Object` | `stats` (from a configuration) | Stats options object or preset name. |
72-
| **[`serverSideRender`](#serversiderender)** | `Boolean` | `undefined` | Instructs the module to enable or disable the server-side rendering mode. |
73-
| **[`writeToDisk`](#writetodisk)** | `Boolean\|Function` | `false` | Instructs the module to write files to the configured location on disk as specified in your `webpack` configuration. |
74-
| **[`outputFileSystem`](#outputfilesystem)** | `Object` | [`memfs`](https://github.com/streamich/memfs) | Set the default file system which will be used by webpack as primary destination of generated files. |
75-
| **[`modifyResponseData`](#modifyresponsedata)** | `Function` | `undefined` | Allows to set up a callback to change the response data. |
63+
| Name | Type | Default | Description |
64+
| :---------------------------------------------: | :---------------------------: | :-------------------------------------------: | :--------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
65+
| **[`methods`](#methods)** | `Array` | `[ 'GET', 'HEAD' ]` | Allows to pass the list of HTTP request methods accepted by the middleware |
66+
| **[`headers`](#headers)** | `Array\ | Object\ | Function` | `undefined` | Allows to pass custom HTTP headers on each request. |
67+
| **[`index`](#index)** | `Boolean\ | String` | `index.html` | If `false` (but not `undefined`), the server will not respond to requests to the root URL. |
68+
| **[`mimeTypes`](#mimetypes)** | `Object` | `undefined` | Allows to register custom mime types or extension mappings. |
69+
| **[`mimeTypeDefault`](#mimetypedefault)** | `String` | `undefined` | Allows to register a default mime type when we can't determine the content type. |
70+
| **[`etag`](#tag)** | `boolean\| "weak"\| "strong"` | `undefined` | Enable or disable etag generation. |
71+
| **[`publicPath`](#publicpath)** | `String` | `output.publicPath` (from a configuration) | The public path that the middleware is bound to. |
72+
| **[`stats`](#stats)** | `Boolean\ | String\ | Object` | `stats` (from a configuration) | Stats options object or preset name. |
73+
| **[`serverSideRender`](#serversiderender)** | `Boolean` | `undefined` | Instructs the module to enable or disable the server-side rendering mode. |
74+
| **[`writeToDisk`](#writetodisk)** | `Boolean\ | Function` | `false` | Instructs the module to write files to the configured location on disk as specified in your `webpack` configuration. |
75+
| **[`outputFileSystem`](#outputfilesystem)** | `Object` | [`memfs`](https://github.com/streamich/memfs) | Set the default file system which will be used by webpack as primary destination of generated files. |
76+
| **[`modifyResponseData`](#modifyresponsedata)** | `Function` | `undefined` | Allows to set up a callback to change the response data. |
7677

7778
The middleware accepts an `options` Object. The following is a property reference for the Object.
7879

@@ -171,6 +172,13 @@ Default: `undefined`
171172

172173
This property allows a user to register a default mime type when we can't determine the content type.
173174

175+
### etag
176+
177+
Type: `"weak" | "strong"`
178+
Default: `undefined`
179+
180+
Enable or disable etag generation. Boolean value use
181+
174182
### publicPath
175183

176184
Type: `String`

Diff for: package-lock.json

+24-9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: src/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ const noop = () => {};
117117
* @property {OutputFileSystem} [outputFileSystem]
118118
* @property {boolean | string} [index]
119119
* @property {ModifyResponseData<RequestInternal, ResponseInternal>} [modifyResponseData]
120+
* @property {"weak" | "strong"} [etag]
120121
*/
121122

122123
/**

Diff for: src/middleware.js

+178
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const getFilenameFromUrl = require("./utils/getFilenameFromUrl");
88
const { setStatusCode, send, pipe } = require("./utils/compatibleAPI");
99
const ready = require("./utils/ready");
1010
const escapeHtml = require("./utils/escapeHtml");
11+
const etag = require("./utils/etag");
12+
const parseTokenList = require("./utils/parseTokenList");
1113

1214
/** @typedef {import("./index.js").NextFunction} NextFunction */
1315
/** @typedef {import("./index.js").IncomingMessage} IncomingMessage */
@@ -27,6 +29,21 @@ function getValueContentRangeHeader(type, size, range) {
2729
return `${type} ${range ? `${range.start}-${range.end}` : "*"}/${size}`;
2830
}
2931

32+
/**
33+
* Parse an HTTP Date into a number.
34+
*
35+
* @param {string} date
36+
* @private
37+
*/
38+
function parseHttpDate(date) {
39+
const timestamp = date && Date.parse(date);
40+
41+
// istanbul ignore next: guard against date.js Date.parse patching
42+
return typeof timestamp === "number" ? timestamp : NaN;
43+
}
44+
45+
const CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/;
46+
3047
/**
3148
* @param {import("fs").ReadStream} stream stream
3249
* @param {boolean} suppress do need suppress?
@@ -174,6 +191,115 @@ function wrapper(context) {
174191
res.end(document);
175192
}
176193

194+
function isConditionalGET() {
195+
return (
196+
req.headers["if-match"] ||
197+
req.headers["if-unmodified-since"] ||
198+
req.headers["if-none-match"] ||
199+
req.headers["if-modified-since"]
200+
);
201+
}
202+
203+
function isPreconditionFailure() {
204+
const match = req.headers["if-match"];
205+
206+
if (match) {
207+
// eslint-disable-next-line no-shadow
208+
const etag = res.getHeader("ETag");
209+
210+
return (
211+
!etag ||
212+
(match !== "*" &&
213+
parseTokenList(match).every(
214+
// eslint-disable-next-line no-shadow
215+
(match) =>
216+
match !== etag &&
217+
match !== `W/${etag}` &&
218+
`W/${match}` !== etag,
219+
))
220+
);
221+
}
222+
223+
return false;
224+
}
225+
226+
/**
227+
* @returns {boolean} is cachable
228+
*/
229+
function isCachable() {
230+
return (
231+
(res.statusCode >= 200 && res.statusCode < 300) ||
232+
res.statusCode === 304
233+
);
234+
}
235+
236+
/**
237+
* @param {import("http").OutgoingHttpHeaders} resHeaders
238+
* @returns {boolean}
239+
*/
240+
function isFresh(resHeaders) {
241+
// Always return stale when Cache-Control: no-cache to support end-to-end reload requests
242+
// https://tools.ietf.org/html/rfc2616#section-14.9.4
243+
const cacheControl = req.headers["cache-control"];
244+
245+
if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl)) {
246+
return false;
247+
}
248+
249+
// if-none-match
250+
const noneMatch = req.headers["if-none-match"];
251+
252+
if (noneMatch && noneMatch !== "*") {
253+
if (!resHeaders.etag) {
254+
return false;
255+
}
256+
257+
const matches = parseTokenList(noneMatch);
258+
259+
let etagStale = true;
260+
261+
for (let i = 0; i < matches.length; i++) {
262+
const match = matches[i];
263+
264+
if (
265+
match === resHeaders.etag ||
266+
match === `W/${resHeaders.etag}` ||
267+
`W/${match}` === resHeaders.etag
268+
) {
269+
etagStale = false;
270+
break;
271+
}
272+
}
273+
274+
if (etagStale) {
275+
return false;
276+
}
277+
}
278+
279+
// A recipient MUST ignore If-Modified-Since if the request contains an If-None-Match header field;
280+
// the condition in If-None-Match is considered to be a more accurate replacement for the condition in If-Modified-Since,
281+
// and the two are only combined for the sake of interoperating with older intermediaries that might not implement If-None-Match.
282+
if (noneMatch) {
283+
return true;
284+
}
285+
286+
// if-modified-since
287+
const modifiedSince = req.headers["if-modified-since"];
288+
289+
if (modifiedSince) {
290+
const lastModified = resHeaders["last-modified"];
291+
const modifiedStale =
292+
!lastModified ||
293+
!(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince));
294+
295+
if (modifiedStale) {
296+
return false;
297+
}
298+
}
299+
300+
return true;
301+
}
302+
177303
async function processRequest() {
178304
// Pipe and SendFile
179305
/** @type {import("./utils/getFilenameFromUrl").Extra} */
@@ -334,6 +460,56 @@ function wrapper(context) {
334460
return;
335461
}
336462

463+
if (context.options.etag && !res.getHeader("ETag")) {
464+
const value =
465+
context.options.etag === "weak"
466+
? /** @type {import("fs").Stats} */ (extra.stats)
467+
: bufferOrStream;
468+
469+
const val = await etag(value);
470+
471+
if (val.buffer) {
472+
bufferOrStream = val.buffer;
473+
}
474+
475+
res.setHeader("ETag", val.hash);
476+
}
477+
478+
// Conditional GET support
479+
if (isConditionalGET()) {
480+
if (isPreconditionFailure()) {
481+
sendError(412, {
482+
modifyResponseData: context.options.modifyResponseData,
483+
});
484+
485+
return;
486+
}
487+
488+
// For Koa
489+
if (res.statusCode === 404) {
490+
setStatusCode(res, 200);
491+
}
492+
493+
if (
494+
isCachable() &&
495+
isFresh({
496+
etag: /** @type {string} */ (res.getHeader("ETag")),
497+
})
498+
) {
499+
setStatusCode(res, 304);
500+
501+
// Remove content header fields
502+
res.removeHeader("Content-Encoding");
503+
res.removeHeader("Content-Language");
504+
res.removeHeader("Content-Length");
505+
res.removeHeader("Content-Range");
506+
res.removeHeader("Content-Type");
507+
res.end();
508+
509+
return;
510+
}
511+
}
512+
337513
if (context.options.modifyResponseData) {
338514
({ data: bufferOrStream, byteLength } =
339515
context.options.modifyResponseData(
@@ -361,6 +537,8 @@ function wrapper(context) {
361537
/** @type {import("fs").ReadStream} */ (bufferOrStream).pipe
362538
) === "function";
363539

540+
console.log(isPipeSupports);
541+
364542
if (!isPipeSupports) {
365543
send(res, /** @type {Buffer} */ (bufferOrStream));
366544
return;

0 commit comments

Comments
 (0)