Skip to content

Commit 18e5683

Browse files
feat: support Last-Modified header generation (#1798)
1 parent b759181 commit 18e5683

File tree

8 files changed

+320
-22
lines changed

8 files changed

+320
-22
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,13 @@ Default: `undefined`
179179

180180
Enable or disable etag generation. Boolean value use
181181

182+
### lastModified
183+
184+
Type: `Boolean`
185+
Default: `undefined`
186+
187+
Enable or disable `Last-Modified` header. Uses the file system's last modified value.
188+
182189
### publicPath
183190

184191
Type: `String`

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ const noop = () => {};
118118
* @property {boolean | string} [index]
119119
* @property {ModifyResponseData<RequestInternal, ResponseInternal>} [modifyResponseData]
120120
* @property {"weak" | "strong"} [etag]
121+
* @property {boolean} [lastModified]
121122
*/
122123

123124
/**

src/middleware.js

Lines changed: 102 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ const onFinishedStream = require("on-finished");
77
const getFilenameFromUrl = require("./utils/getFilenameFromUrl");
88
const { setStatusCode, send, pipe } = require("./utils/compatibleAPI");
99
const ready = require("./utils/ready");
10-
const escapeHtml = require("./utils/escapeHtml");
11-
const etag = require("./utils/etag");
1210
const parseTokenList = require("./utils/parseTokenList");
1311

1412
/** @typedef {import("./index.js").NextFunction} NextFunction */
@@ -33,7 +31,7 @@ function getValueContentRangeHeader(type, size, range) {
3331
* Parse an HTTP Date into a number.
3432
*
3533
* @param {string} date
36-
* @private
34+
* @returns {number}
3735
*/
3836
function parseHttpDate(date) {
3937
const timestamp = date && Date.parse(date);
@@ -140,6 +138,8 @@ function wrapper(context) {
140138
* @returns {void}
141139
*/
142140
function sendError(status, options) {
141+
// eslint-disable-next-line global-require
142+
const escapeHtml = require("./utils/escapeHtml");
143143
const content = statuses[status] || String(status);
144144
let document = `<!DOCTYPE html>
145145
<html lang="en">
@@ -201,17 +201,21 @@ function wrapper(context) {
201201
}
202202

203203
function isPreconditionFailure() {
204-
const match = req.headers["if-match"];
205-
206-
if (match) {
207-
// eslint-disable-next-line no-shadow
204+
// if-match
205+
const ifMatch = req.headers["if-match"];
206+
207+
// A recipient MUST ignore If-Unmodified-Since if the request contains
208+
// an If-Match header field; the condition in If-Match is considered to
209+
// be a more accurate replacement for the condition in
210+
// If-Unmodified-Since, and the two are only combined for the sake of
211+
// interoperating with older intermediaries that might not implement If-Match.
212+
if (ifMatch) {
208213
const etag = res.getHeader("ETag");
209214

210215
return (
211216
!etag ||
212-
(match !== "*" &&
213-
parseTokenList(match).every(
214-
// eslint-disable-next-line no-shadow
217+
(ifMatch !== "*" &&
218+
parseTokenList(ifMatch).every(
215219
(match) =>
216220
match !== etag &&
217221
match !== `W/${etag}` &&
@@ -220,6 +224,23 @@ function wrapper(context) {
220224
);
221225
}
222226

227+
// if-unmodified-since
228+
const ifUnmodifiedSince = req.headers["if-unmodified-since"];
229+
230+
if (ifUnmodifiedSince) {
231+
const unmodifiedSince = parseHttpDate(ifUnmodifiedSince);
232+
233+
// A recipient MUST ignore the If-Unmodified-Since header field if the
234+
// received field-value is not a valid HTTP-date.
235+
if (!isNaN(unmodifiedSince)) {
236+
const lastModified = parseHttpDate(
237+
/** @type {string} */ (res.getHeader("Last-Modified")),
238+
);
239+
240+
return isNaN(lastModified) || lastModified > unmodifiedSince;
241+
}
242+
}
243+
223244
return false;
224245
}
225246

@@ -288,9 +309,17 @@ function wrapper(context) {
288309

289310
if (modifiedSince) {
290311
const lastModified = resHeaders["last-modified"];
312+
const parsedHttpDate = parseHttpDate(modifiedSince);
313+
314+
// A recipient MUST ignore the If-Modified-Since header field if the
315+
// received field-value is not a valid HTTP-date, or if the request
316+
// method is neither GET nor HEAD.
317+
if (isNaN(parsedHttpDate)) {
318+
return true;
319+
}
320+
291321
const modifiedStale =
292-
!lastModified ||
293-
!(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince));
322+
!lastModified || !(parseHttpDate(lastModified) <= parsedHttpDate);
294323

295324
if (modifiedStale) {
296325
return false;
@@ -300,6 +329,38 @@ function wrapper(context) {
300329
return true;
301330
}
302331

332+
function isRangeFresh() {
333+
const ifRange =
334+
/** @type {string | undefined} */
335+
(req.headers["if-range"]);
336+
337+
if (!ifRange) {
338+
return true;
339+
}
340+
341+
// if-range as etag
342+
if (ifRange.indexOf('"') !== -1) {
343+
const etag = /** @type {string | undefined} */ (res.getHeader("ETag"));
344+
345+
if (!etag) {
346+
return true;
347+
}
348+
349+
return Boolean(etag && ifRange.indexOf(etag) !== -1);
350+
}
351+
352+
// if-range as modified date
353+
const lastModified =
354+
/** @type {string | undefined} */
355+
(res.getHeader("Last-Modified"));
356+
357+
if (!lastModified) {
358+
return true;
359+
}
360+
361+
return parseHttpDate(lastModified) <= parseHttpDate(ifRange);
362+
}
363+
303364
async function processRequest() {
304365
// Pipe and SendFile
305366
/** @type {import("./utils/getFilenameFromUrl").Extra} */
@@ -372,16 +433,25 @@ function wrapper(context) {
372433
res.setHeader("Accept-Ranges", "bytes");
373434
}
374435

375-
const rangeHeader = /** @type {string} */ (req.headers.range);
376-
377436
let len = /** @type {import("fs").Stats} */ (extra.stats).size;
378437
let offset = 0;
379438

439+
const rangeHeader = /** @type {string} */ (req.headers.range);
440+
380441
if (rangeHeader && BYTES_RANGE_REGEXP.test(rangeHeader)) {
381-
// eslint-disable-next-line global-require
382-
const parsedRanges = require("range-parser")(len, rangeHeader, {
383-
combine: true,
384-
});
442+
let parsedRanges =
443+
/** @type {import("range-parser").Ranges | import("range-parser").Result | []} */
444+
(
445+
// eslint-disable-next-line global-require
446+
require("range-parser")(len, rangeHeader, {
447+
combine: true,
448+
})
449+
);
450+
451+
// If-Range support
452+
if (!isRangeFresh()) {
453+
parsedRanges = [];
454+
}
385455

386456
if (parsedRanges === -1) {
387457
context.logger.error("Unsatisfiable range for 'Range' header.");
@@ -460,13 +530,22 @@ function wrapper(context) {
460530
return;
461531
}
462532

533+
if (context.options.lastModified && !res.getHeader("Last-Modified")) {
534+
const modified =
535+
/** @type {import("fs").Stats} */
536+
(extra.stats).mtime.toUTCString();
537+
538+
res.setHeader("Last-Modified", modified);
539+
}
540+
463541
if (context.options.etag && !res.getHeader("ETag")) {
464542
const value =
465543
context.options.etag === "weak"
466544
? /** @type {import("fs").Stats} */ (extra.stats)
467545
: bufferOrStream;
468546

469-
const val = await etag(value);
547+
// eslint-disable-next-line global-require
548+
const val = await require("./utils/etag")(value);
470549

471550
if (val.buffer) {
472551
bufferOrStream = val.buffer;
@@ -493,7 +572,10 @@ function wrapper(context) {
493572
if (
494573
isCachable() &&
495574
isFresh({
496-
etag: /** @type {string} */ (res.getHeader("ETag")),
575+
etag: /** @type {string | undefined} */ (res.getHeader("ETag")),
576+
"last-modified":
577+
/** @type {string | undefined} */
578+
(res.getHeader("Last-Modified")),
497579
})
498580
) {
499581
setStatusCode(res, 304);
@@ -537,8 +619,6 @@ function wrapper(context) {
537619
/** @type {import("fs").ReadStream} */ (bufferOrStream).pipe
538620
) === "function";
539621

540-
console.log(isPipeSupports);
541-
542622
if (!isPipeSupports) {
543623
send(res, /** @type {Buffer} */ (bufferOrStream));
544624
return;

src/options.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,11 @@
134134
"description": "Enable or disable etag generation.",
135135
"link": "https://github.com/webpack/webpack-dev-middleware#etag",
136136
"enum": ["weak", "strong"]
137+
},
138+
"lastModified": {
139+
"description": "Enable or disable `Last-Modified` header. Uses the file system's last modified value.",
140+
"link": "https://github.com/webpack/webpack-dev-middleware#lastmodified",
141+
"type": "boolean"
137142
}
138143
},
139144
"additionalProperties": false

test/__snapshots__/validation-options.test.js.snap.webpack5

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,20 @@ exports[`validation should throw an error on the "index" option with "0" value 1
7777
* options.index should be a non-empty string."
7878
`;
7979

80+
exports[`validation should throw an error on the "lastModified" option with "0" value 1`] = `
81+
"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema.
82+
- options.lastModified should be a boolean.
83+
-> Enable or disable \`Last-Modified\` header. Uses the file system's last modified value.
84+
-> Read more at https://github.com/webpack/webpack-dev-middleware#lastmodified"
85+
`;
86+
87+
exports[`validation should throw an error on the "lastModified" option with "foo" value 1`] = `
88+
"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema.
89+
- options.lastModified should be a boolean.
90+
-> Enable or disable \`Last-Modified\` header. Uses the file system's last modified value.
91+
-> Read more at https://github.com/webpack/webpack-dev-middleware#lastmodified"
92+
`;
93+
8094
exports[`validation should throw an error on the "methods" option with "{}" value 1`] = `
8195
"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema.
8296
- options.methods should be an array:

0 commit comments

Comments
 (0)