Skip to content
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

feat: support Last-Modified header generation #1798

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 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 @@
* 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 @@
* @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 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 @@
);
}

// 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 @@

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;

Check warning on line 318 in src/middleware.js

View check run for this annotation

Codecov / codecov/patch

src/middleware.js#L318

Added line #L318 was not covered by tests
}

const modifiedStale =
!lastModified ||
!(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince));
!lastModified || !(parseHttpDate(lastModified) <= parsedHttpDate);

if (modifiedStale) {
return false;
Expand All @@ -300,6 +329,38 @@
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"));

Check warning on line 343 in src/middleware.js

View check run for this annotation

Codecov / codecov/patch

src/middleware.js#L343

Added line #L343 was not covered by tests

if (!etag) {
return true;

Check warning on line 346 in src/middleware.js

View check run for this annotation

Codecov / codecov/patch

src/middleware.js#L346

Added line #L346 was not covered by tests
}

return Boolean(etag && ifRange.indexOf(etag) !== -1);
}

// if-range as modified date
const lastModified =
/** @type {string | undefined} */
(res.getHeader("Last-Modified"));

Check warning on line 355 in src/middleware.js

View check run for this annotation

Codecov / codecov/patch

src/middleware.js#L355

Added line #L355 was not covered by tests

if (!lastModified) {
return true;

Check warning on line 358 in src/middleware.js

View check run for this annotation

Codecov / codecov/patch

src/middleware.js#L358

Added line #L358 was not covered by tests
}

return parseHttpDate(lastModified) <= parseHttpDate(ifRange);

Check warning on line 361 in src/middleware.js

View check run for this annotation

Codecov / codecov/patch

src/middleware.js#L361

Added line #L361 was not covered by tests
}

async function processRequest() {
// Pipe and SendFile
/** @type {import("./utils/getFilenameFromUrl").Extra} */
Expand Down Expand Up @@ -372,16 +433,25 @@
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 = [];

Check warning on line 453 in src/middleware.js

View check run for this annotation

Codecov / codecov/patch

src/middleware.js#L453

Added line #L453 was not covered by tests
}

if (parsedRanges === -1) {
context.logger.error("Unsatisfiable range for 'Range' header.");
Expand Down Expand Up @@ -460,13 +530,22 @@
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 @@
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 @@
/** @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
Loading