diff --git a/README.md b/README.md index 02de5f84d..683ea7fed 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,13 @@ Default: `undefined` Enable or disable etag generation. Boolean value use +### lastModified + +Type: `Boolean` +Default: `undefined` + +Enable or disable `Last-Modified` header. Uses the file system's last modified value. + ### publicPath Type: `String` diff --git a/src/index.js b/src/index.js index e8fc9f736..d0c634c40 100644 --- a/src/index.js +++ b/src/index.js @@ -118,6 +118,7 @@ const noop = () => {}; * @property {boolean | string} [index] * @property {ModifyResponseData} [modifyResponseData] * @property {"weak" | "strong"} [etag] + * @property {boolean} [lastModified] */ /** diff --git a/src/middleware.js b/src/middleware.js index 84356beef..e1bf81125 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -7,8 +7,6 @@ const onFinishedStream = require("on-finished"); const getFilenameFromUrl = require("./utils/getFilenameFromUrl"); const { setStatusCode, send, pipe } = require("./utils/compatibleAPI"); const ready = require("./utils/ready"); -const escapeHtml = require("./utils/escapeHtml"); -const etag = require("./utils/etag"); const parseTokenList = require("./utils/parseTokenList"); /** @typedef {import("./index.js").NextFunction} NextFunction */ @@ -33,7 +31,7 @@ function getValueContentRangeHeader(type, size, range) { * Parse an HTTP Date into a number. * * @param {string} date - * @private + * @returns {number} */ function parseHttpDate(date) { const timestamp = date && Date.parse(date); @@ -140,6 +138,8 @@ function wrapper(context) { * @returns {void} */ function sendError(status, options) { + // eslint-disable-next-line global-require + const escapeHtml = require("./utils/escapeHtml"); const content = statuses[status] || String(status); let document = ` @@ -201,17 +201,21 @@ function wrapper(context) { } function isPreconditionFailure() { - const match = req.headers["if-match"]; - - if (match) { - // eslint-disable-next-line no-shadow + // if-match + const ifMatch = req.headers["if-match"]; + + // A recipient MUST ignore If-Unmodified-Since if the request contains + // an If-Match header field; the condition in If-Match is considered to + // be a more accurate replacement for the condition in + // If-Unmodified-Since, and the two are only combined for the sake of + // interoperating with older intermediaries that might not implement If-Match. + if (ifMatch) { const etag = res.getHeader("ETag"); return ( !etag || - (match !== "*" && - parseTokenList(match).every( - // eslint-disable-next-line no-shadow + (ifMatch !== "*" && + parseTokenList(ifMatch).every( (match) => match !== etag && match !== `W/${etag}` && @@ -220,6 +224,23 @@ function wrapper(context) { ); } + // if-unmodified-since + const ifUnmodifiedSince = req.headers["if-unmodified-since"]; + + if (ifUnmodifiedSince) { + const unmodifiedSince = parseHttpDate(ifUnmodifiedSince); + + // A recipient MUST ignore the If-Unmodified-Since header field if the + // received field-value is not a valid HTTP-date. + if (!isNaN(unmodifiedSince)) { + const lastModified = parseHttpDate( + /** @type {string} */ (res.getHeader("Last-Modified")), + ); + + return isNaN(lastModified) || lastModified > unmodifiedSince; + } + } + return false; } @@ -288,9 +309,17 @@ function wrapper(context) { if (modifiedSince) { const lastModified = resHeaders["last-modified"]; + const parsedHttpDate = parseHttpDate(modifiedSince); + + // A recipient MUST ignore the If-Modified-Since header field if the + // received field-value is not a valid HTTP-date, or if the request + // method is neither GET nor HEAD. + if (isNaN(parsedHttpDate)) { + return true; + } + const modifiedStale = - !lastModified || - !(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince)); + !lastModified || !(parseHttpDate(lastModified) <= parsedHttpDate); if (modifiedStale) { return false; @@ -300,6 +329,38 @@ function wrapper(context) { return true; } + function isRangeFresh() { + const ifRange = + /** @type {string | undefined} */ + (req.headers["if-range"]); + + if (!ifRange) { + return true; + } + + // if-range as etag + if (ifRange.indexOf('"') !== -1) { + const etag = /** @type {string | undefined} */ (res.getHeader("ETag")); + + if (!etag) { + return true; + } + + return Boolean(etag && ifRange.indexOf(etag) !== -1); + } + + // if-range as modified date + const lastModified = + /** @type {string | undefined} */ + (res.getHeader("Last-Modified")); + + if (!lastModified) { + return true; + } + + return parseHttpDate(lastModified) <= parseHttpDate(ifRange); + } + async function processRequest() { // Pipe and SendFile /** @type {import("./utils/getFilenameFromUrl").Extra} */ @@ -372,16 +433,25 @@ function wrapper(context) { res.setHeader("Accept-Ranges", "bytes"); } - const rangeHeader = /** @type {string} */ (req.headers.range); - let len = /** @type {import("fs").Stats} */ (extra.stats).size; let offset = 0; + const rangeHeader = /** @type {string} */ (req.headers.range); + if (rangeHeader && BYTES_RANGE_REGEXP.test(rangeHeader)) { - // eslint-disable-next-line global-require - const parsedRanges = require("range-parser")(len, rangeHeader, { - combine: true, - }); + let parsedRanges = + /** @type {import("range-parser").Ranges | import("range-parser").Result | []} */ + ( + // eslint-disable-next-line global-require + require("range-parser")(len, rangeHeader, { + combine: true, + }) + ); + + // If-Range support + if (!isRangeFresh()) { + parsedRanges = []; + } if (parsedRanges === -1) { context.logger.error("Unsatisfiable range for 'Range' header."); @@ -460,13 +530,22 @@ function wrapper(context) { return; } + if (context.options.lastModified && !res.getHeader("Last-Modified")) { + const modified = + /** @type {import("fs").Stats} */ + (extra.stats).mtime.toUTCString(); + + res.setHeader("Last-Modified", modified); + } + if (context.options.etag && !res.getHeader("ETag")) { const value = context.options.etag === "weak" ? /** @type {import("fs").Stats} */ (extra.stats) : bufferOrStream; - const val = await etag(value); + // eslint-disable-next-line global-require + const val = await require("./utils/etag")(value); if (val.buffer) { bufferOrStream = val.buffer; @@ -493,7 +572,10 @@ function wrapper(context) { if ( isCachable() && isFresh({ - etag: /** @type {string} */ (res.getHeader("ETag")), + etag: /** @type {string | undefined} */ (res.getHeader("ETag")), + "last-modified": + /** @type {string | undefined} */ + (res.getHeader("Last-Modified")), }) ) { setStatusCode(res, 304); @@ -537,8 +619,6 @@ function wrapper(context) { /** @type {import("fs").ReadStream} */ (bufferOrStream).pipe ) === "function"; - console.log(isPipeSupports); - if (!isPipeSupports) { send(res, /** @type {Buffer} */ (bufferOrStream)); return; diff --git a/src/options.json b/src/options.json index 357db9bf4..50443e268 100644 --- a/src/options.json +++ b/src/options.json @@ -134,6 +134,11 @@ "description": "Enable or disable etag generation.", "link": "https://github.com/webpack/webpack-dev-middleware#etag", "enum": ["weak", "strong"] + }, + "lastModified": { + "description": "Enable or disable `Last-Modified` header. Uses the file system's last modified value.", + "link": "https://github.com/webpack/webpack-dev-middleware#lastmodified", + "type": "boolean" } }, "additionalProperties": false diff --git a/test/__snapshots__/validation-options.test.js.snap.webpack5 b/test/__snapshots__/validation-options.test.js.snap.webpack5 index 797b295bf..aa4d0dfef 100644 --- a/test/__snapshots__/validation-options.test.js.snap.webpack5 +++ b/test/__snapshots__/validation-options.test.js.snap.webpack5 @@ -77,6 +77,20 @@ exports[`validation should throw an error on the "index" option with "0" value 1 * options.index should be a non-empty string." `; +exports[`validation should throw an error on the "lastModified" option with "0" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.lastModified should be a boolean. + -> Enable or disable \`Last-Modified\` header. Uses the file system's last modified value. + -> Read more at https://github.com/webpack/webpack-dev-middleware#lastmodified" +`; + +exports[`validation should throw an error on the "lastModified" option with "foo" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.lastModified should be a boolean. + -> Enable or disable \`Last-Modified\` header. Uses the file system's last modified value. + -> Read more at https://github.com/webpack/webpack-dev-middleware#lastmodified" +`; + exports[`validation should throw an error on the "methods" option with "{}" value 1`] = ` "Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. - options.methods should be an array: diff --git a/test/middleware.test.js b/test/middleware.test.js index eb068af16..c47a18b01 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -4372,5 +4372,190 @@ describe.each([ }); }); }); + + describe("lastModified", () => { + describe("should work and generate Last-Modified header", () => { + beforeEach(async () => { + const compiler = getCompiler(webpackConfig); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { + lastModified: true, + }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + function parseHttpDate(date) { + const timestamp = date && Date.parse(date); + + // istanbul ignore next: guard against date.js Date.parse patching + return typeof timestamp === "number" ? timestamp : NaN; + } + + it('should return the "200" code for the "GET" request to the bundle file and set "Last-Modified"', async () => { + const response = await req.get(`/bundle.js`); + + expect(response.statusCode).toEqual(200); + expect(response.headers["last-modified"]).toBeDefined(); + }); + + it('should return the "304" code for the "GET" request to the bundle file with "Last-Modified" and "if-unmodified-since" header', async () => { + const response1 = await req.get(`/bundle.js`); + + expect(response1.statusCode).toEqual(200); + expect(response1.headers["last-modified"]).toBeDefined(); + + const response2 = await req + .get(`/bundle.js`) + .set("if-unmodified-since", response1.headers["last-modified"]); + + expect(response2.statusCode).toEqual(304); + expect(response2.headers["last-modified"]).toBeDefined(); + + const response3 = await req + .get(`/bundle.js`) + .set("if-unmodified-since", "Fri, 29 Mar 2020 10:25:50 GMT"); + + expect(response3.statusCode).toEqual(412); + }); + + it('should return the "304" code for the "GET" request to the bundle file with "Last-Modified" and "if-modified-since" header', async () => { + const response1 = await req.get(`/bundle.js`); + + expect(response1.statusCode).toEqual(200); + expect(response1.headers["last-modified"]).toBeDefined(); + + const response2 = await req + .get(`/bundle.js`) + .set("if-modified-since", response1.headers["last-modified"]); + + expect(response2.statusCode).toEqual(304); + expect(response2.headers["last-modified"]).toBeDefined(); + + const response3 = await req + .get(`/bundle.js`) + .set( + "if-modified-since", + new Date( + parseHttpDate(response1.headers["last-modified"]) - 1000, + ).toUTCString(), + ); + + expect(response3.statusCode).toEqual(200); + expect(response3.headers["last-modified"]).toBeDefined(); + }); + + it('should return the "412" code for the "GET" request to the bundle file with etag and "if-unmodified-since" header', async () => { + const response1 = await req.get(`/bundle.js`); + + expect(response1.statusCode).toEqual(200); + expect(response1.headers["last-modified"]).toBeDefined(); + + const response2 = await req + .get(`/bundle.js`) + .set( + "if-unmodified-since", + new Date( + parseHttpDate(response1.headers["last-modified"]) - 1000, + ).toUTCString(), + ); + + expect(response2.statusCode).toEqual(412); + }); + + it('should return the "200" code for the "GET" request to the bundle file with etag and "if-match" and "cache-control: no-cache" header', async () => { + const response1 = await req.get(`/bundle.js`); + + expect(response1.statusCode).toEqual(200); + expect(response1.headers["last-modified"]).toBeDefined(); + + const response2 = await req + .get(`/bundle.js`) + .set("if-unmodified-since", response1.headers["last-modified"]) + .set("Cache-Control", "no-cache"); + + expect(response2.statusCode).toEqual(200); + expect(response1.headers["last-modified"]).toBeDefined(); + }); + }); + + describe('should work and prefer "if-match" and "if-none-match"', () => { + beforeEach(async () => { + const compiler = getCompiler(webpackConfig); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { + etag: "weak", + lastModified: true, + }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + function parseHttpDate(date) { + const timestamp = date && Date.parse(date); + + // istanbul ignore next: guard against date.js Date.parse patching + return typeof timestamp === "number" ? timestamp : NaN; + } + + it('should return the "304" code for the "GET" request to the bundle file and prefer "if-match" over "if-unmodified-since"', async () => { + const response1 = await req.get(`/bundle.js`); + + expect(response1.statusCode).toEqual(200); + expect(response1.headers["last-modified"]).toBeDefined(); + expect(response1.headers.etag).toBeDefined(); + + const response2 = await req + .get(`/bundle.js`) + .set("if-match", response1.headers.etag) + .set( + "if-unmodified-since", + new Date( + parseHttpDate(response1.headers["last-modified"]) - 1000, + ).toUTCString(), + ); + + expect(response2.statusCode).toEqual(304); + expect(response2.headers["last-modified"]).toBeDefined(); + expect(response2.headers.etag).toBeDefined(); + }); + + it('should return the "304" code for the "GET" request to the bundle file and prefer "if-none-match" over "if-modified-since"', async () => { + const response1 = await req.get(`/bundle.js`); + + expect(response1.statusCode).toEqual(200); + expect(response1.headers["last-modified"]).toBeDefined(); + expect(response1.headers.etag).toBeDefined(); + + const response2 = await req + .get(`/bundle.js`) + .set("if-none-match", response1.headers.etag) + .set( + "if-modified-since", + new Date( + parseHttpDate(response1.headers["last-modified"]) - 1000, + ).toUTCString(), + ); + + expect(response2.statusCode).toEqual(304); + expect(response2.headers["last-modified"]).toBeDefined(); + expect(response2.headers.etag).toBeDefined(); + }); + }); + }); }); }); diff --git a/test/validation-options.test.js b/test/validation-options.test.js index f1d8f8328..62632f001 100644 --- a/test/validation-options.test.js +++ b/test/validation-options.test.js @@ -71,6 +71,10 @@ describe("validation", () => { success: ["weak", "strong"], failure: ["foo", 0], }, + lastModified: { + success: [true, false], + failure: ["foo", 0], + }, }; function stringifyValue(value) { diff --git a/types/index.d.ts b/types/index.d.ts index 98b3509b7..1080632b0 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -91,6 +91,7 @@ export = wdm; * @property {boolean | string} [index] * @property {ModifyResponseData} [modifyResponseData] * @property {"weak" | "strong"} [etag] + * @property {boolean} [lastModified] */ /** * @template {IncomingMessage} RequestInternal @@ -352,6 +353,7 @@ type Options< | ModifyResponseData | undefined; etag?: "strong" | "weak" | undefined; + lastModified?: boolean | undefined; }; type Middleware< RequestInternal extends import("http").IncomingMessage,