From 3e14473c4c290f37cfb0c7a5c61860cf6e5a8bfc Mon Sep 17 00:00:00 2001 From: "alexander.akait" Date: Thu, 15 Aug 2024 21:40:35 +0300 Subject: [PATCH] feat: added the `cacheImmutable` option to cache immutable assets (assets with a hash in file name like `image.e12ab567.jpg`) --- README.md | 12 +- src/index.js | 3 +- src/middleware.js | 78 +++++---- src/options.json | 5 + src/utils/getFilenameFromUrl.js | 9 +- src/utils/getPaths.js | 6 +- .../validation-options.test.js.snap.webpack5 | 20 +++ test/fixtures/immutable.js | 1 + test/fixtures/webpack.immutable.config.js | 17 ++ test/middleware.test.js | 151 +++++++++++++++++- test/validation-options.test.js | 14 ++ types/index.d.ts | 8 +- types/utils/getFilenameFromUrl.d.ts | 1 + types/utils/getPaths.d.ts | 1 + 14 files changed, 282 insertions(+), 44 deletions(-) create mode 100644 test/fixtures/immutable.js create mode 100644 test/fixtures/webpack.immutable.config.js diff --git a/README.md b/README.md index a9b3b2e33..ef6ed5be2 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ See [below](#other-servers) for an example of use with fastify. | **[`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. | | **[`lastModified`](#lastmodified)** | `boolean` | `undefined` | Enable or disable `Last-Modified` header. Uses the file system's last modified value. | -| **[`cacheControl`](#cachecontrol)** | `boolean\|number\|string\|Object` | `undefined` | Enable or disable `Last-Modified` header. Uses the file system's last modified value. | +| **[`cacheControl`](#cachecontrol)** | `boolean\|number\|string\|Object` | `undefined` | Enable or disable setting `Cache-Control` response header. | +| **[`cacheImmutable`](#cacheimmutable)** | `boolean\` | `undefined` | Enable or disable setting `Cache-Control: public, max-age=31536000, immutable` response header for immutable assets. | | **[`publicPath`](#publicpath)** | `string` | `undefined` | 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. | @@ -202,6 +203,15 @@ Depending on the setting, the following headers will be generated: Enable or disable setting `Cache-Control` response header. +### cacheImmutable + +Type: `Boolean` +Default: `undefined` + +Enable or disable setting `Cache-Control: public, max-age=31536000, immutable` response header for immutable assets (i.e. asset with a hash like `image.a4c12bde.jpg`). +Immutable assets are assets that have their hash in the file name therefore they can be cached, because if you change their contents the file name will be changed. +Take preference over the `cacheControl` option if the asset was defined as immutable. + ### publicPath Type: `String` diff --git a/src/index.js b/src/index.js index dc947ba6e..6f9e8ad10 100644 --- a/src/index.js +++ b/src/index.js @@ -118,7 +118,8 @@ const noop = () => {}; * @property {ModifyResponseData} [modifyResponseData] * @property {"weak" | "strong"} [etag] * @property {boolean} [lastModified] - * @property {boolean | number | string | { maxAge: number, immutable: boolean }} [cacheControl] + * @property {boolean | number | string | { maxAge?: number, immutable?: boolean }} [cacheControl] + * @property {boolean} [cacheImmutable] */ /** diff --git a/src/middleware.js b/src/middleware.js index 3d6c6e61f..2b4bdcb60 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -551,39 +551,43 @@ function wrapper(context) { setResponseHeader(res, "Accept-Ranges", "bytes"); } - if ( - context.options.cacheControl && - !getResponseHeader(res, "Cache-Control") - ) { - const { cacheControl } = context.options; - - let cacheControlValue; - - if (typeof cacheControl === "boolean") { - cacheControlValue = "public, max-age=31536000"; - } else if (typeof cacheControl === "number") { - const maxAge = Math.floor( - Math.min(Math.max(0, cacheControl), MAX_MAX_AGE) / 1000, - ); - - cacheControlValue = `public, max-age=${maxAge}`; - } else if (typeof cacheControl === "string") { - cacheControlValue = cacheControl; - } else { - const maxAge = cacheControl.maxAge - ? Math.floor( - Math.min(Math.max(0, cacheControl.maxAge), MAX_MAX_AGE) / 1000, - ) - : MAX_MAX_AGE; - - cacheControlValue = `public, max-age=${maxAge}`; + if (!getResponseHeader(res, "Cache-Control")) { + // TODO enable the `cacheImmutable` by default for the next major release + const cacheControl = + context.options.cacheImmutable && extra.immutable + ? { immutable: true } + : context.options.cacheControl; + + if (cacheControl) { + let cacheControlValue; + + if (typeof cacheControl === "boolean") { + cacheControlValue = "public, max-age=31536000"; + } else if (typeof cacheControl === "number") { + const maxAge = Math.floor( + Math.min(Math.max(0, cacheControl), MAX_MAX_AGE) / 1000, + ); - if (cacheControl.immutable) { - cacheControlValue += ", immutable"; + cacheControlValue = `public, max-age=${maxAge}`; + } else if (typeof cacheControl === "string") { + cacheControlValue = cacheControl; + } else { + const maxAge = cacheControl.maxAge + ? Math.floor( + Math.min(Math.max(0, cacheControl.maxAge), MAX_MAX_AGE) / + 1000, + ) + : MAX_MAX_AGE / 1000; + + cacheControlValue = `public, max-age=${maxAge}`; + + if (cacheControl.immutable) { + cacheControlValue += ", immutable"; + } } - } - setResponseHeader(res, "Cache-Control", cacheControlValue); + setResponseHeader(res, "Cache-Control", cacheControlValue); + } } if ( @@ -604,7 +608,7 @@ function wrapper(context) { /** @type {undefined | Buffer | ReadStream} */ let bufferOrStream; - /** @type {number} */ + /** @type {number | undefined} */ let byteLength; const rangeHeader = getRangeHeader(); @@ -781,13 +785,17 @@ function wrapper(context) { req, res, bufferOrStream, - // @ts-ignore - byteLength, + /** @type {number} */ + (byteLength), )); } - // @ts-ignore - setResponseHeader(res, "Content-Length", byteLength); + setResponseHeader( + res, + "Content-Length", + /** @type {number} */ + (byteLength), + ); if (method === "HEAD") { if (!isPartialContent) { diff --git a/src/options.json b/src/options.json index 1a80b7b25..0a55b69c9 100644 --- a/src/options.json +++ b/src/options.json @@ -167,6 +167,11 @@ "additionalProperties": false } ] + }, + "cacheImmutable": { + "description": "Enable or disable setting `Cache-Control: public, max-age=31536000, immutable` response header for immutable assets (i.e. asset with a hash in file name like `image.a4c12bde.jpg`).", + "link": "https://github.com/webpack/webpack-dev-middleware#cacheimmutable", + "type": "boolean" } }, "additionalProperties": false diff --git a/src/utils/getFilenameFromUrl.js b/src/utils/getFilenameFromUrl.js index 7dd7ddb80..2f034060f 100644 --- a/src/utils/getFilenameFromUrl.js +++ b/src/utils/getFilenameFromUrl.js @@ -24,6 +24,7 @@ const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/; * @typedef {Object} Extra * @property {import("fs").Stats=} stats * @property {number=} errorCode + * @property {boolean=} immutable */ /** @@ -65,7 +66,7 @@ function getFilenameFromUrl(context, url, extra = {}) { return; } - for (const { publicPath, outputPath } of paths) { + for (const { publicPath, outputPath, assetsInfo } of paths) { /** @type {string | undefined} */ let filename; /** @type {URL} */ @@ -122,6 +123,12 @@ function getFilenameFromUrl(context, url, extra = {}) { if (extra.stats.isFile()) { foundFilename = filename; + const assetInfo = assetsInfo.get( + pathname.slice(publicPathObject.pathname.length), + ); + + extra.immutable = assetInfo ? assetInfo.immutable : false; + break; } else if ( extra.stats.isDirectory() && diff --git a/src/utils/getPaths.js b/src/utils/getPaths.js index 7b538ea4f..9ccd72852 100644 --- a/src/utils/getPaths.js +++ b/src/utils/getPaths.js @@ -35,7 +35,11 @@ function getPaths(context) { ? compilation.getPath(compilation.outputOptions.publicPath) : ""; - publicPaths.push({ outputPath, publicPath }); + publicPaths.push({ + outputPath, + publicPath, + assetsInfo: compilation.assetsInfo, + }); } return publicPaths; diff --git a/test/__snapshots__/validation-options.test.js.snap.webpack5 b/test/__snapshots__/validation-options.test.js.snap.webpack5 index aa4d0dfef..eb08c61a2 100644 --- a/test/__snapshots__/validation-options.test.js.snap.webpack5 +++ b/test/__snapshots__/validation-options.test.js.snap.webpack5 @@ -1,5 +1,25 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`validation should throw an error on the "cacheControl" option with "{"unknown":true,"maxAge":10000}" value 1`] = ` +"Invalid options object. Dev Middleware has been initialized using an options object that does not match the API schema. + - options.cacheControl has an unknown property 'unknown'. These properties are valid: + object { maxAge?, immutable? }" +`; + +exports[`validation should throw an error on the "cacheImmutable" 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.cacheImmutable should be a boolean. + -> Enable or disable setting \`Cache-Control: public, max-age=31536000, immutable\` response header for immutable assets (i.e. asset with a hash in file name like \`image.a4c12bde.jpg\`). + -> Read more at https://github.com/webpack/webpack-dev-middleware#cacheimmutable" +`; + +exports[`validation should throw an error on the "cacheImmutable" 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.cacheImmutable should be a boolean. + -> Enable or disable setting \`Cache-Control: public, max-age=31536000, immutable\` response header for immutable assets (i.e. asset with a hash in file name like \`image.a4c12bde.jpg\`). + -> Read more at https://github.com/webpack/webpack-dev-middleware#cacheimmutable" +`; + exports[`validation should throw an error on the "etag" 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.etag should be one of these: diff --git a/test/fixtures/immutable.js b/test/fixtures/immutable.js new file mode 100644 index 000000000..699af94dd --- /dev/null +++ b/test/fixtures/immutable.js @@ -0,0 +1 @@ +new URL("./svg.svg", import.meta.url); diff --git a/test/fixtures/webpack.immutable.config.js b/test/fixtures/webpack.immutable.config.js new file mode 100644 index 000000000..4d4cdfc93 --- /dev/null +++ b/test/fixtures/webpack.immutable.config.js @@ -0,0 +1,17 @@ +'use strict'; + +const path = require('path'); + +module.exports = { + mode: 'development', + context: path.resolve(__dirname), + entry: './immutable.js', + output: { + publicPath: "/static/", + path: path.resolve(__dirname, '../outputs/basic'), + }, + infrastructureLogging: { + level: 'none' + }, + stats: 'normal' +}; diff --git a/test/middleware.test.js b/test/middleware.test.js index 5cd0bf81e..64ec0821c 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -28,6 +28,7 @@ import webpackClientServerConfig from "./fixtures/webpack.client.server.config"; import getCompilerHooks from "./helpers/getCompilerHooks"; import webpackPublicPathConfig from "./fixtures/webpack.public-path.config"; import webpackMultiDevServerFalseConfig from "./fixtures/webpack.array.dev-server-false"; +import webpackConfigImmutable from "./fixtures/webpack.immutable.config"; // Suppress unnecessary stats output global.console.log = jest.fn(); @@ -5512,7 +5513,7 @@ describe.each([ }); }); - describe.only("cacheControl", () => { + describe("cacheControl", () => { describe("should work and don't generate `Cache-Control` header by default", () => { beforeEach(async () => { const compiler = getCompiler(webpackConfig); @@ -5671,7 +5672,7 @@ describe.each([ expect(response.statusCode).toEqual(200); expect(response.headers["cache-control"]).toBeDefined(); expect(response.headers["cache-control"]).toBe( - "public, max-age=31536000000, immutable", + "public, max-age=31536000, immutable", ); }); }); @@ -5704,6 +5705,152 @@ describe.each([ expect(response.headers["cache-control"]).toBe("public, max-age=100"); }); }); + + describe("should work and generate `Cache-Control` header when it is an object without max-age and immutable", () => { + beforeEach(async () => { + const compiler = getCompiler(webpackConfig); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { + cacheControl: {}, + }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + it('should return the "200" code for the "GET" request to the bundle file and don\'t generate `Cache-Control` header', async () => { + const response = await req.get(`/bundle.js`); + + expect(response.statusCode).toEqual(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000", + ); + }); + }); + }); + + describe("cacheImmutable", () => { + describe("should work and generate `Cache-Control` header for immutable assets with publicPath", () => { + beforeEach(async () => { + const compiler = getCompiler(webpackConfigImmutable); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: true }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + it('should return the "200" code for the "GET" request to the bundle file and don\'t generate `Cache-Control` header', async () => { + const response = await req.get(`/static/main.js`); + + expect(response.statusCode).toEqual(200); + expect(response.headers["cache-control"]).toBeUndefined(); + }); + + it('should return the "200" code for the "GET" request to the bundle file and generate `Cache-Control` header', async () => { + const response = await req.get(`/static/6076fc274f403ebb2d09.svg`); + + expect(response.statusCode).toEqual(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000, immutable", + ); + }); + }); + + describe("should work and generate `Cache-Control` header for immutable assets without publicPath", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: true }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + it('should return the "200" code for the "GET" request to the bundle file and don\'t generate `Cache-Control` header', async () => { + const response = await req.get(`/main.js`); + + expect(response.statusCode).toEqual(200); + expect(response.headers["cache-control"]).toBeUndefined(); + }); + + it('should return the "200" code for the "GET" request to the bundle file and `Cache-Control` header', async () => { + const response = await req.get(`/6076fc274f403ebb2d09.svg`); + + expect(response.statusCode).toEqual(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000, immutable", + ); + }); + }); + + describe("should work and generate `Cache-Control` header for immutable assets and take preference over the `cacheControl` option", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: true, cacheControl: 1000000 }, + ); + }); + + afterEach(async () => { + await close(server, instance); + }); + + it('should return the "200" code for the "GET" request to the bundle file and generate `Cache-Control` header', async () => { + const response = await req.get(`/main.js`); + + expect(response.statusCode).toEqual(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=1000", + ); + }); + + it('should return the "200" code for the "GET" request to the bundle file and generate `Cache-Control` header', async () => { + const response = await req.get(`/6076fc274f403ebb2d09.svg`); + + expect(response.statusCode).toEqual(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000, immutable", + ); + }); + }); }); }); }); diff --git a/test/validation-options.test.js b/test/validation-options.test.js index 62632f001..addf2b8f8 100644 --- a/test/validation-options.test.js +++ b/test/validation-options.test.js @@ -75,6 +75,20 @@ describe("validation", () => { success: [true, false], failure: ["foo", 0], }, + cacheControl: { + success: [ + true, + false, + 10000, + "max-age=100", + { immutable: true, maxAge: 10000 }, + ], + failure: [{ unknown: true, maxAge: 10000 }], + }, + cacheImmutable: { + success: [true, false], + failure: ["foo", 0], + }, }; function stringifyValue(value) { diff --git a/types/index.d.ts b/types/index.d.ts index d6f8930a1..f6886e1bd 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -90,7 +90,8 @@ export = wdm; * @property {ModifyResponseData} [modifyResponseData] * @property {"weak" | "strong"} [etag] * @property {boolean} [lastModified] - * @property {boolean | number | string | { maxAge: number, immutable: boolean }} [cacheControl] + * @property {boolean | number | string | { maxAge?: number, immutable?: boolean }} [cacheControl] + * @property {boolean} [cacheImmutable] */ /** * @template {IncomingMessage} [RequestInternal=IncomingMessage] @@ -360,10 +361,11 @@ type Options< | number | boolean | { - maxAge: number; - immutable: boolean; + maxAge?: number; + immutable?: boolean; } | undefined; + cacheImmutable?: boolean | undefined; }; type Middleware< RequestInternal extends IncomingMessage = import("http").IncomingMessage, diff --git a/types/utils/getFilenameFromUrl.d.ts b/types/utils/getFilenameFromUrl.d.ts index 18ff67ff6..08fc1dcb6 100644 --- a/types/utils/getFilenameFromUrl.d.ts +++ b/types/utils/getFilenameFromUrl.d.ts @@ -21,6 +21,7 @@ declare namespace getFilenameFromUrl { type Extra = { stats?: import("fs").Stats | undefined; errorCode?: number | undefined; + immutable?: boolean | undefined; }; type IncomingMessage = import("../index.js").IncomingMessage; type ServerResponse = import("../index.js").ServerResponse; diff --git a/types/utils/getPaths.d.ts b/types/utils/getPaths.d.ts index ecf88cbdd..ace131cd1 100644 --- a/types/utils/getPaths.d.ts +++ b/types/utils/getPaths.d.ts @@ -17,6 +17,7 @@ declare function getPaths< ): { outputPath: string; publicPath: string; + assetsInfo: Map; }[]; declare namespace getPaths { export { Compiler, Stats, MultiStats, IncomingMessage, ServerResponse };