Skip to content

feat: support Last-Modified header generation #1798

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading