Skip to content

Commit

Permalink
feat: support Last-Modified header generation (#1798)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexander-akait committed Mar 29, 2024
1 parent b759181 commit 18e5683
Show file tree
Hide file tree
Showing 8 changed files with 320 additions and 22 deletions.
7 changes: 7 additions & 0 deletions README.md
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Expand Up @@ -118,6 +118,7 @@ const noop = () => {};
* @property {boolean | string} [index]
* @property {ModifyResponseData<RequestInternal, ResponseInternal>} [modifyResponseData]
* @property {"weak" | "strong"} [etag]
* @property {boolean} [lastModified]
*/

/**
Expand Down
124 changes: 102 additions & 22 deletions src/middleware.js
Expand Up @@ -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 */
Expand All @@ -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);
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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}` &&
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
Expand All @@ -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} */
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/options.json
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions test/__snapshots__/validation-options.test.js.snap.webpack5
Expand Up @@ -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:
Expand Down

0 comments on commit 18e5683

Please sign in to comment.