From f7529c3188efa1885593993d912155ef2188fda5 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Thu, 15 Aug 2024 20:13:26 +0300 Subject: [PATCH] feat: allow to configure the `Cache-Control` header (#1923) --- README.md | 44 ++++++--- src/index.js | 1 + src/middleware.js | 37 ++++++++ src/options.json | 28 ++++++ test/middleware.test.js | 194 ++++++++++++++++++++++++++++++++++++++++ types/index.d.ts | 10 +++ 6 files changed, 300 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7b295144a..a9b3b2e33 100644 --- a/README.md +++ b/README.md @@ -60,20 +60,22 @@ 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. | -| **[`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. | +| 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. | +| **[`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. | +| **[`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. | +| **[`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. @@ -186,6 +188,20 @@ Default: `undefined` Enable or disable `Last-Modified` header. Uses the file system's last modified value. +### cacheControl + +Type: `Boolean | Number | String | { maxAge?: number, immutable?: boolean }` +Default: `undefined` + +Depending on the setting, the following headers will be generated: + +- `Boolean` - `Cache-Control: public, max-age=31536000000` +- `Number` - `Cache-Control: public, max-age=YOUR_NUMBER` +- `String` - `Cache-Control: YOUR_STRING` +- `{ maxAge?: number, immutable?: boolean }` - `Cache-Control: public, max-age=YOUR_MAX_AGE_or_31536000000`, also `, immutable` can be added if you set the `immutable` option to `true` + +Enable or disable setting `Cache-Control` response header. + ### publicPath Type: `String` diff --git a/src/index.js b/src/index.js index dc54a75e9..dc947ba6e 100644 --- a/src/index.js +++ b/src/index.js @@ -118,6 +118,7 @@ const noop = () => {}; * @property {ModifyResponseData} [modifyResponseData] * @property {"weak" | "strong"} [etag] * @property {boolean} [lastModified] + * @property {boolean | number | string | { maxAge: number, immutable: boolean }} [cacheControl] */ /** diff --git a/src/middleware.js b/src/middleware.js index 032e28273..3d6c6e61f 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -118,6 +118,8 @@ const parseRangeHeaders = memorize( }, ); +const MAX_MAX_AGE = 31536000000; + /** * @template {IncomingMessage} Request * @template {ServerResponse} Response @@ -549,6 +551,41 @@ 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 (cacheControl.immutable) { + cacheControlValue += ", immutable"; + } + } + + setResponseHeader(res, "Cache-Control", cacheControlValue); + } + if ( context.options.lastModified && !getResponseHeader(res, "Last-Modified") diff --git a/src/options.json b/src/options.json index 50443e268..1a80b7b25 100644 --- a/src/options.json +++ b/src/options.json @@ -139,6 +139,34 @@ "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" + }, + "cacheControl": { + "description": "Enable or disable setting `Cache-Control` response header.", + "link": "https://github.com/webpack/webpack-dev-middleware#cachecontrol", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "properties": { + "maxAge": { + "type": "number" + }, + "immutable": { + "type": "boolean" + } + }, + "additionalProperties": false + } + ] } }, "additionalProperties": false diff --git a/test/middleware.test.js b/test/middleware.test.js index 7da7f4484..5cd0bf81e 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -5511,5 +5511,199 @@ describe.each([ }); }); }); + + describe.only("cacheControl", () => { + describe("should work and don't generate `Cache-Control` header by default", () => { + beforeEach(async () => { + const compiler = getCompiler(webpackConfig); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + ); + }); + + 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"]).toBeUndefined(); + }); + }); + + describe("should work and generate `Cache-Control` header when it is `true`", () => { + beforeEach(async () => { + const compiler = getCompiler(webpackConfig); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheControl: 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(`/bundle.js`); + + expect(response.statusCode).toEqual(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000", + ); + }); + }); + + describe("should work and generate `Cache-Control` header when it is a number", () => { + beforeEach(async () => { + const compiler = getCompiler(webpackConfig); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheControl: 100000 }, + ); + }); + + 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=100"); + }); + }); + + describe("should work and generate `Cache-Control` header when it is a string", () => { + beforeEach(async () => { + const compiler = getCompiler(webpackConfig); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheControl: "max-age=123456" }, + ); + }); + + 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("max-age=123456"); + }); + }); + + describe("should work and generate `Cache-Control` header when it is an object with max-age and immutable", () => { + beforeEach(async () => { + const compiler = getCompiler(webpackConfig); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { + cacheControl: { + maxAge: 100000, + immutable: 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(`/bundle.js`); + + expect(response.statusCode).toEqual(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=100, immutable", + ); + }); + }); + + describe("should work and generate `Cache-Control` header when it is an object without max-age, but with immutable", () => { + beforeEach(async () => { + const compiler = getCompiler(webpackConfig); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { + cacheControl: { + immutable: 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(`/bundle.js`); + + expect(response.statusCode).toEqual(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000000, immutable", + ); + }); + }); + + describe("should work and generate `Cache-Control` header when it is an object with max-age, but without immutable", () => { + beforeEach(async () => { + const compiler = getCompiler(webpackConfig); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { + cacheControl: { + maxAge: 100000, + }, + }, + ); + }); + + 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=100"); + }); + }); + }); }); }); diff --git a/types/index.d.ts b/types/index.d.ts index 750074985..d6f8930a1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -90,6 +90,7 @@ export = wdm; * @property {ModifyResponseData} [modifyResponseData] * @property {"weak" | "strong"} [etag] * @property {boolean} [lastModified] + * @property {boolean | number | string | { maxAge: number, immutable: boolean }} [cacheControl] */ /** * @template {IncomingMessage} [RequestInternal=IncomingMessage] @@ -354,6 +355,15 @@ type Options< | undefined; etag?: "strong" | "weak" | undefined; lastModified?: boolean | undefined; + cacheControl?: + | string + | number + | boolean + | { + maxAge: number; + immutable: boolean; + } + | undefined; }; type Middleware< RequestInternal extends IncomingMessage = import("http").IncomingMessage,