diff --git a/README.md b/README.md index b1a0915..9cb9b3c 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ See [below](#other-servers) for examples of use with other servers. | **[`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 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. | +| **[`cacheImmutable`](#cacheimmutable)** | `boolean` | `true` | 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. | @@ -196,21 +196,25 @@ 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` +- `Boolean` - `Cache-Control: public, max-age=31536000` +- `Number` - `Cache-Control: public, max-age=YOUR_NUMBER_IN_SECONDS` - `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` +- `{ maxAge?: number, immutable?: boolean }` - `Cache-Control: public, max-age=YOUR_MAX_AGE_IN_SECONDS_or_31536000`, also `, immutable` is added when you set the `immutable` option to `true` + +Numeric `cacheControl` and `cacheControl.maxAge` values are interpreted as milliseconds, clamped to `0..31536000000`, and converted to seconds for the response header. Enable or disable setting `Cache-Control` response header. ### cacheImmutable Type: `Boolean` -Default: `undefined` +Default: `true` 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. +When omitted, immutable assets use this header by default. +Set `cacheImmutable: false` to fall back to the `cacheControl` option even for immutable assets. +This takes precedence over the `cacheControl` option only when the asset was defined as immutable and `cacheImmutable` is not `false`. ### publicPath diff --git a/src/index.js b/src/index.js index 656db0e..09bb80c 100644 --- a/src/index.js +++ b/src/index.js @@ -126,7 +126,7 @@ const noop = () => {}; * @property {"weak" | "strong"=} etag options to generate etag header * @property {boolean=} lastModified options to generate last modified header * @property {(boolean | number | string | { maxAge?: number, immutable?: boolean })=} cacheControl options to generate cache headers - * @property {boolean=} cacheImmutable is cache immutable + * @property {boolean=} cacheImmutable enable immutable cache headers for immutable assets (defaults to true when omitted) */ /** diff --git a/src/middleware.js b/src/middleware.js index 0a8cf7d..973fef1 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -605,40 +605,36 @@ function wrapper(context) { } 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, - ); - - 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"; - } + const { cacheControl, cacheImmutable } = context.options; + const useImmutableCache = + (cacheImmutable === undefined || cacheImmutable) && extra.immutable; + + let cacheControlValue; + + if (useImmutableCache) { + cacheControlValue = `public, max-age=${Math.floor(MAX_MAX_AGE / 1000)}, immutable`; + } else if (cacheControl === true) { + cacheControlValue = `public, max-age=${Math.floor(MAX_MAX_AGE / 1000)}`; + } else if (typeof cacheControl === "number") { + const maxAge = Math.min(Math.max(0, cacheControl), MAX_MAX_AGE); + + cacheControlValue = `public, max-age=${Math.floor(maxAge / 1000)}`; + } else if (typeof cacheControl === "string") { + cacheControlValue = cacheControl; + } else if (cacheControl) { + const maxAge = + cacheControl.maxAge !== undefined + ? Math.min(Math.max(0, cacheControl.maxAge), MAX_MAX_AGE) + : MAX_MAX_AGE; + + cacheControlValue = `public, max-age=${Math.floor(maxAge / 1000)}`; + + if (cacheControl.immutable) { + cacheControlValue += ", immutable"; } + } + if (cacheControlValue) { setResponseHeader(res, "Cache-Control", cacheControlValue); } } diff --git a/test/middleware.test.js b/test/middleware.test.js index 9eff918..b65611c 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -6131,7 +6131,6 @@ describe.each([ name, framework, compiler, - { cacheImmutable: true }, ); }); @@ -6176,7 +6175,6 @@ describe.each([ name, framework, compiler, - { cacheImmutable: true }, ); }); @@ -6221,7 +6219,7 @@ describe.each([ name, framework, compiler, - { cacheImmutable: true, cacheControl: 1000000 }, + { cacheControl: 1000000 }, ); }); @@ -6253,6 +6251,364 @@ describe.each([ ); }); }); + + describe("should not generate `Cache-Control` header for immutable assets when cacheImmutable is false", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false }, + ); + }); + + 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).toBe(200); + expect(response.headers["cache-control"]).toBeUndefined(); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and don\'t generate `Cache-Control` header', async () => { + await req.get("/main.js"); + + const response = await req.get( + `/${getAssetFilename(instance, ".svg")}`, + ); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeUndefined(); + }); + }); + + describe("should use cacheControl option when cacheImmutable is false even for immutable assets", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false, 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 from cacheControl option', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(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 immutable asset and generate `Cache-Control` header from cacheControl option (not immutable)', async () => { + await req.get("/main.js"); + + const response = await req.get( + `/${getAssetFilename(instance, ".svg")}`, + ); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=1000", + ); + }); + }); + + describe("should use cacheControl string option when cacheImmutable is false", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false, cacheControl: "max-age=500" }, + ); + }); + + 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 from cacheControl string option without immutable', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe("max-age=500"); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and generate `Cache-Control` header from cacheControl string option without immutable', async () => { + await req.get("/main.js"); + + const response = await req.get( + `/${getAssetFilename(instance, ".svg")}`, + ); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe("max-age=500"); + }); + }); + + describe("should use cacheControl object option when cacheImmutable is false", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false, cacheControl: { maxAge: 2000000 } }, + ); + }); + + 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 from cacheControl object option without immutable', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=2000", + ); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and generate `Cache-Control` header from cacheControl object option without immutable', async () => { + await req.get("/main.js"); + + const response = await req.get( + `/${getAssetFilename(instance, ".svg")}`, + ); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=2000", + ); + }); + }); + + describe("should use cacheControl object option (with only immutable: true) when cacheImmutable is false, and add 'immutable' to Cache-Control header", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false, cacheControl: { immutable: true } }, + ); + }); + + 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 from cacheControl object option with immutable', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000, immutable", + ); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and generate `Cache-Control` header from cacheControl object option with immutable', async () => { + await req.get("/main.js"); + + const response = await req.get( + `/${getAssetFilename(instance, ".svg")}`, + ); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000, immutable", + ); + }); + }); + + describe("should use cacheControl object option with explicit immutable false", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheControl: { maxAge: 3000000, immutable: false } }, + ); + }); + + 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 without immutable when explicitly set to false', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=3000", + ); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and generate `Cache-Control` header for the immutable asset by default', async () => { + await req.get("/main.js"); + + const response = await req.get( + `/${getAssetFilename(instance, ".svg")}`, + ); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000, immutable", + ); + }); + }); + + describe("should use cacheControl boolean option when cacheImmutable is false", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false, cacheControl: true }, + ); + }); + + 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 from cacheControl boolean option without immutable', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000", + ); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and generate `Cache-Control` header from cacheControl boolean option without immutable', async () => { + await req.get("/main.js"); + + const response = await req.get( + `/${getAssetFilename(instance, ".svg")}`, + ); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=31536000", + ); + }); + }); + + describe("should use cacheControl number option when cacheImmutable is false without immutable", () => { + beforeEach(async () => { + const compiler = getCompiler({ + ...webpackConfigImmutable, + output: { + path: path.resolve(__dirname, "./outputs/basic"), + }, + }); + + [server, req, instance] = await frameworkFactory( + name, + framework, + compiler, + { cacheImmutable: false, cacheControl: 5000000 }, + ); + }); + + 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 from cacheControl number option without immutable', async () => { + const response = await req.get("/main.js"); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=5000", + ); + }); + + it('should return the "200" code for the "GET" request to the immutable asset and generate `Cache-Control` header from cacheControl number option without immutable', async () => { + await req.get("/main.js"); + + const response = await req.get( + `/${getAssetFilename(instance, ".svg")}`, + ); + + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBeDefined(); + expect(response.headers["cache-control"]).toBe( + "public, max-age=5000", + ); + }); + }); }); }); }); diff --git a/types/index.d.ts b/types/index.d.ts index 3c32688..b76d003 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -95,7 +95,7 @@ * @property {"weak" | "strong"=} etag options to generate etag header * @property {boolean=} lastModified options to generate last modified header * @property {(boolean | number | string | { maxAge?: number, immutable?: boolean })=} cacheControl options to generate cache headers - * @property {boolean=} cacheImmutable is cache immutable + * @property {boolean=} cacheImmutable enable immutable cache headers for immutable assets (defaults to true when omitted) */ /** * @template {IncomingMessage} [RequestInternal=IncomingMessage] @@ -364,7 +364,7 @@ export type Options< ) | undefined; /** - * is cache immutable + * enable immutable cache headers for immutable assets (defaults to true when omitted) */ cacheImmutable?: boolean | undefined; };