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: allow to configure the Cache-Control header #1923

Merged
merged 1 commit into from
Aug 15, 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
44 changes: 30 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ const noop = () => {};
* @property {ModifyResponseData<RequestInternal, ResponseInternal>} [modifyResponseData]
* @property {"weak" | "strong"} [etag]
* @property {boolean} [lastModified]
* @property {boolean | number | string | { maxAge: number, immutable: boolean }} [cacheControl]
*/

/**
Expand Down
37 changes: 37 additions & 0 deletions src/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ const parseRangeHeaders = memorize(
},
);

const MAX_MAX_AGE = 31536000000;

/**
* @template {IncomingMessage} Request
* @template {ServerResponse} Response
Expand Down Expand Up @@ -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")
Expand Down
28 changes: 28 additions & 0 deletions src/options.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
194 changes: 194 additions & 0 deletions test/middleware.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
});
});
10 changes: 10 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export = wdm;
* @property {ModifyResponseData<RequestInternal, ResponseInternal>} [modifyResponseData]
* @property {"weak" | "strong"} [etag]
* @property {boolean} [lastModified]
* @property {boolean | number | string | { maxAge: number, immutable: boolean }} [cacheControl]
*/
/**
* @template {IncomingMessage} [RequestInternal=IncomingMessage]
Expand Down Expand Up @@ -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,
Expand Down
Loading