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: etag support #1797

Merged
merged 5 commits into from
Mar 28, 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
4 changes: 3 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
"mycustom",
"commitlint",
"nosniff",
"deoptimize"
"deoptimize",
"etag",
"cachable"
],
"ignorePaths": [
"CHANGELOG.md",
Expand Down
34 changes: 21 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,20 @@ See [below](#other-servers) for an example of use with fastify.

## Options

| Name | Type | Default | Description |
| :---------------------------------------------: | :-----------------------: | :-------------------------------------------: | :------------------------------------------------------------------------------------------------------------------- |
| **[`methods`](#methods)** | `Array` | `[ 'GET', 'HEAD' ]` | Allows to pass the list of HTTP request methods accepted by the middleware |
| **[`headers`](#headers)** | `Array\|Object\|Function` | `undefined` | Allows to pass custom HTTP headers on each request. |
| **[`index`](#index)** | `Boolean\|String` | `index.html` | If `false` (but not `undefined`), the server will not respond to requests to the root URL. |
| **[`mimeTypes`](#mimetypes)** | `Object` | `undefined` | Allows to register custom mime types or extension mappings. |
| **[`mimeTypeDefault`](#mimetypedefault)** | `String` | `undefined` | Allows to register a default mime type when we can't determine the content type. |
| **[`publicPath`](#publicpath)** | `String` | `output.publicPath` (from a configuration) | The public path that the middleware is bound to. |
| **[`stats`](#stats)** | `Boolean\|String\|Object` | `stats` (from a configuration) | Stats options object or preset name. |
| **[`serverSideRender`](#serversiderender)** | `Boolean` | `undefined` | Instructs the module to enable or disable the server-side rendering mode. |
| **[`writeToDisk`](#writetodisk)** | `Boolean\|Function` | `false` | Instructs the module to write files to the configured location on disk as specified in your `webpack` configuration. |
| **[`outputFileSystem`](#outputfilesystem)** | `Object` | [`memfs`](https://github.com/streamich/memfs) | Set the default file system which will be used by webpack as primary destination of generated files. |
| **[`modifyResponseData`](#modifyresponsedata)** | `Function` | `undefined` | Allows to set up a callback to change the response data. |
| Name | Type | Default | Description |
| :---------------------------------------------: | :---------------------------: | :-------------------------------------------: | :--------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| **[`methods`](#methods)** | `Array` | `[ 'GET', 'HEAD' ]` | Allows to pass the list of HTTP request methods accepted by the middleware |
| **[`headers`](#headers)** | `Array\ | Object\ | Function` | `undefined` | Allows to pass custom HTTP headers on each request. |
| **[`index`](#index)** | `Boolean\ | String` | `index.html` | If `false` (but not `undefined`), the server will not respond to requests to the root URL. |
| **[`mimeTypes`](#mimetypes)** | `Object` | `undefined` | Allows to register custom mime types or extension mappings. |
| **[`mimeTypeDefault`](#mimetypedefault)** | `String` | `undefined` | Allows to register a default mime type when we can't determine the content type. |
| **[`etag`](#tag)** | `boolean\| "weak"\| "strong"` | `undefined` | Enable or disable etag generation. |
| **[`publicPath`](#publicpath)** | `String` | `output.publicPath` (from a configuration) | The public path that the middleware is bound to. |
| **[`stats`](#stats)** | `Boolean\ | String\ | Object` | `stats` (from a configuration) | Stats options object or preset name. |
| **[`serverSideRender`](#serversiderender)** | `Boolean` | `undefined` | Instructs the module to enable or disable the server-side rendering mode. |
| **[`writeToDisk`](#writetodisk)** | `Boolean\ | Function` | `false` | Instructs the module to write files to the configured location on disk as specified in your `webpack` configuration. |
| **[`outputFileSystem`](#outputfilesystem)** | `Object` | [`memfs`](https://github.com/streamich/memfs) | Set the default file system which will be used by webpack as primary destination of generated files. |
| **[`modifyResponseData`](#modifyresponsedata)** | `Function` | `undefined` | Allows to set up a callback to change the response data. |

The middleware accepts an `options` Object. The following is a property reference for the Object.

Expand Down Expand Up @@ -171,6 +172,13 @@ Default: `undefined`

This property allows a user to register a default mime type when we can't determine the content type.

### etag

Type: `"weak" | "strong"`
Default: `undefined`

Enable or disable etag generation. Boolean value use

### publicPath

Type: `String`
Expand Down
33 changes: 24 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ const noop = () => {};
* @property {OutputFileSystem} [outputFileSystem]
* @property {boolean | string} [index]
* @property {ModifyResponseData<RequestInternal, ResponseInternal>} [modifyResponseData]
* @property {"weak" | "strong"} [etag]
*/

/**
Expand Down
178 changes: 178 additions & 0 deletions src/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
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 */
/** @typedef {import("./index.js").IncomingMessage} IncomingMessage */
Expand All @@ -27,6 +29,21 @@
return `${type} ${range ? `${range.start}-${range.end}` : "*"}/${size}`;
}

/**
* Parse an HTTP Date into a number.
*
* @param {string} date
* @private
*/
function parseHttpDate(date) {

Check warning on line 38 in src/middleware.js

View check run for this annotation

Codecov / codecov/patch

src/middleware.js#L38

Added line #L38 was not covered by tests
const timestamp = date && Date.parse(date);

// istanbul ignore next: guard against date.js Date.parse patching
return typeof timestamp === "number" ? timestamp : NaN;
}

const CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/;

/**
* @param {import("fs").ReadStream} stream stream
* @param {boolean} suppress do need suppress?
Expand Down Expand Up @@ -174,6 +191,115 @@
res.end(document);
}

function isConditionalGET() {
return (
req.headers["if-match"] ||
req.headers["if-unmodified-since"] ||
req.headers["if-none-match"] ||
req.headers["if-modified-since"]
);
}

function isPreconditionFailure() {
const match = req.headers["if-match"];

if (match) {
// eslint-disable-next-line no-shadow
const etag = res.getHeader("ETag");

return (
!etag ||
(match !== "*" &&
parseTokenList(match).every(
// eslint-disable-next-line no-shadow
(match) =>
match !== etag &&
match !== `W/${etag}` &&
`W/${match}` !== etag,
))
);
}

return false;
}

/**
* @returns {boolean} is cachable
*/
function isCachable() {
return (
(res.statusCode >= 200 && res.statusCode < 300) ||
res.statusCode === 304

Check warning on line 232 in src/middleware.js

View check run for this annotation

Codecov / codecov/patch

src/middleware.js#L232

Added line #L232 was not covered by tests
);
}

/**
* @param {import("http").OutgoingHttpHeaders} resHeaders
* @returns {boolean}
*/
function isFresh(resHeaders) {
// Always return stale when Cache-Control: no-cache to support end-to-end reload requests
// https://tools.ietf.org/html/rfc2616#section-14.9.4
const cacheControl = req.headers["cache-control"];

if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl)) {
return false;
}

// if-none-match
const noneMatch = req.headers["if-none-match"];

if (noneMatch && noneMatch !== "*") {
if (!resHeaders.etag) {
return false;

Check warning on line 254 in src/middleware.js

View check run for this annotation

Codecov / codecov/patch

src/middleware.js#L254

Added line #L254 was not covered by tests
}

const matches = parseTokenList(noneMatch);

let etagStale = true;

for (let i = 0; i < matches.length; i++) {
const match = matches[i];

if (
match === resHeaders.etag ||
match === `W/${resHeaders.etag}` ||
`W/${match}` === resHeaders.etag
) {
etagStale = false;
break;
}
}

if (etagStale) {
return false;
}
}

// A recipient MUST ignore If-Modified-Since if the request contains an If-None-Match header field;
// the condition in If-None-Match is considered to be a more accurate replacement for the condition in If-Modified-Since,
// and the two are only combined for the sake of interoperating with older intermediaries that might not implement If-None-Match.
if (noneMatch) {
return true;
}

// if-modified-since
const modifiedSince = req.headers["if-modified-since"];

if (modifiedSince) {
const lastModified = resHeaders["last-modified"];

Check warning on line 290 in src/middleware.js

View check run for this annotation

Codecov / codecov/patch

src/middleware.js#L290

Added line #L290 was not covered by tests
const modifiedStale =
!lastModified ||
!(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince));

Check warning on line 293 in src/middleware.js

View check run for this annotation

Codecov / codecov/patch

src/middleware.js#L293

Added line #L293 was not covered by tests

if (modifiedStale) {
return false;

Check warning on line 296 in src/middleware.js

View check run for this annotation

Codecov / codecov/patch

src/middleware.js#L296

Added line #L296 was not covered by tests
}
}

return true;
}

async function processRequest() {
// Pipe and SendFile
/** @type {import("./utils/getFilenameFromUrl").Extra} */
Expand Down Expand Up @@ -334,6 +460,56 @@
return;
}

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);

if (val.buffer) {
bufferOrStream = val.buffer;
}

res.setHeader("ETag", val.hash);
}

// Conditional GET support
if (isConditionalGET()) {
if (isPreconditionFailure()) {
sendError(412, {
modifyResponseData: context.options.modifyResponseData,
});

return;
}

// For Koa
if (res.statusCode === 404) {
setStatusCode(res, 200);
}

if (
isCachable() &&
isFresh({
etag: /** @type {string} */ (res.getHeader("ETag")),
})
) {
setStatusCode(res, 304);

// Remove content header fields
res.removeHeader("Content-Encoding");
res.removeHeader("Content-Language");
res.removeHeader("Content-Length");
res.removeHeader("Content-Range");
res.removeHeader("Content-Type");
res.end();

return;
}
}

if (context.options.modifyResponseData) {
({ data: bufferOrStream, byteLength } =
context.options.modifyResponseData(
Expand Down Expand Up @@ -361,6 +537,8 @@
/** @type {import("fs").ReadStream} */ (bufferOrStream).pipe
) === "function";

console.log(isPipeSupports);

if (!isPipeSupports) {
send(res, /** @type {Buffer} */ (bufferOrStream));
return;
Expand Down
Loading
Loading