From 18e56833327084c22c1ee6bdad123095a68d144a Mon Sep 17 00:00:00 2001
From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com>
Date: Fri, 29 Mar 2024 14:05:53 +0300
Subject: [PATCH] feat: support `Last-Modified` header generation (#1798)

---
 README.md                                     |   7 +
 src/index.js                                  |   1 +
 src/middleware.js                             | 124 +++++++++---
 src/options.json                              |   5 +
 .../validation-options.test.js.snap.webpack5  |  14 ++
 test/middleware.test.js                       | 185 ++++++++++++++++++
 test/validation-options.test.js               |   4 +
 types/index.d.ts                              |   2 +
 8 files changed, 320 insertions(+), 22 deletions(-)

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<RequestInternal, ResponseInternal>} [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 = `<!DOCTYPE html>
 <html lang="en">
@@ -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<RequestInternal, ResponseInternal>} [modifyResponseData]
  * @property {"weak" | "strong"} [etag]
+ * @property {boolean} [lastModified]
  */
 /**
  * @template {IncomingMessage} RequestInternal
@@ -352,6 +353,7 @@ type Options<
     | ModifyResponseData<RequestInternal, ResponseInternal>
     | undefined;
   etag?: "strong" | "weak" | undefined;
+  lastModified?: boolean | undefined;
 };
 type Middleware<
   RequestInternal extends import("http").IncomingMessage,