Skip to content

Commit

Permalink
feat: etag support (#1797)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexander-akait committed Mar 28, 2024
1 parent f69b638 commit b759181
Show file tree
Hide file tree
Showing 16 changed files with 567 additions and 753 deletions.
4 changes: 3 additions & 1 deletion .cspell.json
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
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
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
Expand Up @@ -8,6 +8,8 @@ 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 */
/** @typedef {import("./index.js").IncomingMessage} IncomingMessage */
Expand All @@ -27,6 +29,21 @@ function getValueContentRangeHeader(type, size, range) {
return `${type} ${range ? `${range.start}-${range.end}` : "*"}/${size}`;
}

/**
* Parse an HTTP Date into a number.
*
* @param {string} date
* @private
*/
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;
}

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 @@ function wrapper(context) {
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
);
}

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

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"];
const modifiedStale =
!lastModified ||
!(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince));

if (modifiedStale) {
return false;
}
}

return true;
}

async function processRequest() {
// Pipe and SendFile
/** @type {import("./utils/getFilenameFromUrl").Extra} */
Expand Down Expand Up @@ -334,6 +460,56 @@ function wrapper(context) {
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 @@ function wrapper(context) {
/** @type {import("fs").ReadStream} */ (bufferOrStream).pipe
) === "function";

console.log(isPipeSupports);

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

0 comments on commit b759181

Please sign in to comment.