From ff1018e35130f7a559a29eec63bdfaf182745178 Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Thu, 13 Aug 2026 17:45:41 -0700 Subject: [PATCH] feat: emit Cache-Control headers on cached responses Closes #175. Opt-in via strategy.cacheControl, off by default: cacheControl: { enabled: false, maxAge: 'config', // 'none' | 'config' | scope: 'private', staleWhileRevalidate: null, } Keeps the CacheControlResponseMaxAge union from @pinkasey's #96 but flattens its two-level nesting, since only the response direction is implemented. Milliseconds throughout, branded, with the single ms->s conversion in toSeconds(). A seconds-valued field next to maxAge/ttl is the exact ambiguity behind #126. scope defaults to private, and public is downgraded to private on any route with keys.useAuth rather than merely documented as unsafe; resolveUserStrategy warns at boot naming the content type. Emitted on HIT, on an ETag 304, and on a MISS only after isCacheable says the response will be stored. Never on hitpass, a refused response, the coalesced waiter path, or over a Cache-Control the handler already set. Co-Authored-By: Claude --- .../api/classes/CacheControlConfig.md | 55 +++++ .../api/classes/CachePluginStrategy.md | 29 +-- docs/guide/reference/api/index.md | 2 + .../interfaces/CacheContentTypeConfigInput.md | 20 +- .../api/interfaces/CacheControlConfigInput.md | 19 ++ .../api/interfaces/CacheKeysConfigInput.md | 15 +- .../interfaces/CachePluginStrategyInput.md | 29 +-- .../api/interfaces/CacheRouteConfigInput.md | 14 +- docs/guide/reference/config.md | 106 +++++++++ .../server/src/config/index.ts | 12 + .../server/src/middlewares/recv.ts | 49 +++++ .../server/src/types/CacheControlConfig.ts | 81 +++++++ .../server/src/types/CachePluginStrategy.ts | 13 ++ .../server/src/types/index.ts | 1 + .../server/src/types/inputs.ts | 20 ++ .../src/utils/config/resolveUserStrategy.ts | 28 +++ .../utils/middlewares/buildCacheControl.ts | 70 ++++++ shared/config/cache-strategy.js | 21 ++ .../src/api/category/controllers/category.js | 10 + .../api/category/routes/custom-category.js | 8 + shared/src/bootstrap.js | 10 +- shared/tests/flag-cache-control.test.js | 207 ++++++++++++++++++ 22 files changed, 762 insertions(+), 57 deletions(-) create mode 100644 docs/guide/reference/api/classes/CacheControlConfig.md create mode 100644 docs/guide/reference/api/interfaces/CacheControlConfigInput.md create mode 100644 packages/plugin-rest-cache/server/src/types/CacheControlConfig.ts create mode 100644 packages/plugin-rest-cache/server/src/utils/middlewares/buildCacheControl.ts create mode 100644 shared/tests/flag-cache-control.test.js diff --git a/docs/guide/reference/api/classes/CacheControlConfig.md b/docs/guide/reference/api/classes/CacheControlConfig.md new file mode 100644 index 0000000..9346166 --- /dev/null +++ b/docs/guide/reference/api/classes/CacheControlConfig.md @@ -0,0 +1,55 @@ +# Class: CacheControlConfig + +Defined in: CacheControlConfig.ts:29 + +What `Cache-Control` to put on a response this plugin cached. + +Design and original implementation by `@pinkasey` in +https://github.com/strapi-community/plugin-rest-cache/pull/96, which targeted +Strapi 4 and can no longer be rebased. Carried forward by +https://github.com/strapi-community/plugin-rest-cache/issues/175. + +Simplified from #96's two nested types - a `CacheControlHeaderConfig` +wrapping a `CacheControlResponseHeaderConfig` - into the one flat block +below, because only the response direction is implemented here. Honouring an +incoming request `Cache-Control` is still open, and a wrapper whose only +member today is `response` buys nothing while making every user write +`cacheControl.response.maxAge`. Should the request direction land, it can add +a `cacheControl.request` block without changing any of these names. + +#96's `CacheControlResponseMaxAge` enum - NONE / CONFIG / a number - is kept, +as the union on `maxAge`. That is the part carrying the meaning: "say +nothing", "say what the route is actually cached for", or "say this instead". + +Off by default and meant to stay opt-in: the header moves caching to browsers +and CDNs, where a purge cannot reach it, so every emitted `max-age` is a +window of guaranteed staleness that an operator has to choose knowingly. + +## Constructors + +### Constructor + +```ts +new CacheControlConfig(options?): CacheControlConfig; +``` + +Defined in: CacheControlConfig.ts:65 + +#### Parameters + +| Parameter | Type | +| ------ | ------ | +| `options` | [`CacheControlConfigInput`](../interfaces/CacheControlConfigInput.md) | + +#### Returns + +`CacheControlConfig` + +## Properties + +| Property | Type | Default value | Description | Defined in | +| ------ | ------ | ------ | ------ | ------ | +| `enabled` | `boolean` | `false` | Emit the header at all. | CacheControlConfig.ts:31 | +| `maxAge` | `"none"` \| [`Milliseconds`](../type-aliases/Milliseconds.md) \| `"config"` | `'config'` | `none` omits the `max-age` directive, `config` takes the route's resolved `maxAge`, and a number overrides it. That number is MILLISECONDS, like every other duration in this plugin, even though the directive it ends up in is seconds. A single field that meant seconds while `maxAge`, `ttl` and `staleWhileRevalidate` meant milliseconds is precisely the ambiguity behind https://github.com/strapi-community/plugin-rest-cache/issues/126. The one conversion lives in buildCacheControl. | CacheControlConfig.ts:44 | +| `scope` | `"public"` \| `"private"` | `'private'` | `private` means only the end client may store the response; `public` also allows shared caches such as a CDN. Defaults to `private`, the answer that cannot leak: a wrongly-public response is served to the wrong person by a cache the server does not own. `public` is downgraded to `private` on any route whose keys identify the caller - see buildCacheControl. | CacheControlConfig.ts:55 | +| `staleWhileRevalidate` | [`Milliseconds`](../type-aliases/Milliseconds.md) | `null` | How long a cache may keep serving the stale response while it refreshes, or null to omit the directive. Milliseconds, for the same reason as `maxAge` above. | CacheControlConfig.ts:63 | diff --git a/docs/guide/reference/api/classes/CachePluginStrategy.md b/docs/guide/reference/api/classes/CachePluginStrategy.md index 015bd30..76c3eea 100644 --- a/docs/guide/reference/api/classes/CachePluginStrategy.md +++ b/docs/guide/reference/api/classes/CachePluginStrategy.md @@ -1,6 +1,6 @@ # Class: CachePluginStrategy -Defined in: [CachePluginStrategy.ts:7](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L7) +Defined in: [CachePluginStrategy.ts:8](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L8) ## Constructors @@ -10,7 +10,7 @@ Defined in: [CachePluginStrategy.ts:7](https://github.com/strapi-community/plugi new CachePluginStrategy(options?): CachePluginStrategy; ``` -Defined in: [CachePluginStrategy.ts:34](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L34) +Defined in: [CachePluginStrategy.ts:42](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L42) #### Parameters @@ -26,15 +26,16 @@ Defined in: [CachePluginStrategy.ts:34](https://github.com/strapi-community/plug | Property | Type | Default value | Description | Defined in | | ------ | ------ | ------ | ------ | ------ | -| `clearRelatedCache` | `boolean` | `true` | - | [CachePluginStrategy.ts:23](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L23) | -| `contentTypes` | [`CacheContentTypeConfig`](CacheContentTypeConfig.md)[] | `[]` | - | [CachePluginStrategy.ts:30](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L30) | -| `debug` | `boolean` | `false` | - | [CachePluginStrategy.ts:8](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L8) | -| `enableAdminCTBMiddleware` | `boolean` | `true` | - | [CachePluginStrategy.ts:14](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L14) | -| `enableContentApiPurge` | `boolean` | `false` | - | [CachePluginStrategy.ts:18](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L18) | -| `enableDocumentServiceMiddleware` | `boolean` | `true` | - | [CachePluginStrategy.ts:16](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L16) | -| `enableEtag` | `boolean` | `false` | - | [CachePluginStrategy.ts:10](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L10) | -| `enableXCacheHeaders` | `boolean` | `false` | - | [CachePluginStrategy.ts:12](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L12) | -| `keys` | [`CacheKeysConfig`](CacheKeysConfig.md) | `undefined` | - | [CachePluginStrategy.ts:32](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L32) | -| `keysPrefix` | `string` | `''` | - | [CachePluginStrategy.ts:28](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L28) | -| `maxAge` | [`Milliseconds`](../type-aliases/Milliseconds.md) | `undefined` | Milliseconds. | [CachePluginStrategy.ts:26](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L26) | -| `resetOnStartup` | `boolean` | `false` | - | [CachePluginStrategy.ts:20](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L20) | +| `cacheControl` | [`CacheControlConfig`](CacheControlConfig.md) | `undefined` | Whether cached responses advertise their caching downstream. **See** https://github.com/strapi-community/plugin-rest-cache/issues/175 | [CachePluginStrategy.ts:40](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L40) | +| `clearRelatedCache` | `boolean` | `true` | - | [CachePluginStrategy.ts:24](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L24) | +| `contentTypes` | [`CacheContentTypeConfig`](CacheContentTypeConfig.md)[] | `[]` | - | [CachePluginStrategy.ts:31](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L31) | +| `debug` | `boolean` | `false` | - | [CachePluginStrategy.ts:9](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L9) | +| `enableAdminCTBMiddleware` | `boolean` | `true` | - | [CachePluginStrategy.ts:15](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L15) | +| `enableContentApiPurge` | `boolean` | `false` | - | [CachePluginStrategy.ts:19](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L19) | +| `enableDocumentServiceMiddleware` | `boolean` | `true` | - | [CachePluginStrategy.ts:17](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L17) | +| `enableEtag` | `boolean` | `false` | - | [CachePluginStrategy.ts:11](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L11) | +| `enableXCacheHeaders` | `boolean` | `false` | - | [CachePluginStrategy.ts:13](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L13) | +| `keys` | [`CacheKeysConfig`](CacheKeysConfig.md) | `undefined` | - | [CachePluginStrategy.ts:33](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L33) | +| `keysPrefix` | `string` | `''` | - | [CachePluginStrategy.ts:29](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L29) | +| `maxAge` | [`Milliseconds`](../type-aliases/Milliseconds.md) | `undefined` | Milliseconds. | [CachePluginStrategy.ts:27](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L27) | +| `resetOnStartup` | `boolean` | `false` | - | [CachePluginStrategy.ts:21](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts#L21) | diff --git a/docs/guide/reference/api/index.md b/docs/guide/reference/api/index.md index 013d13e..a23545a 100644 --- a/docs/guide/reference/api/index.md +++ b/docs/guide/reference/api/index.md @@ -3,6 +3,7 @@ ## Classes - [CacheContentTypeConfig](classes/CacheContentTypeConfig.md) +- [CacheControlConfig](classes/CacheControlConfig.md) - [CacheKeysConfig](classes/CacheKeysConfig.md) - [CachePluginStrategy](classes/CachePluginStrategy.md) - [CacheProvider](classes/CacheProvider.md) @@ -11,6 +12,7 @@ ## Interfaces - [CacheContentTypeConfigInput](interfaces/CacheContentTypeConfigInput.md) +- [CacheControlConfigInput](interfaces/CacheControlConfigInput.md) - [CacheKeysConfigInput](interfaces/CacheKeysConfigInput.md) - [CachePluginStrategyInput](interfaces/CachePluginStrategyInput.md) - [CacheProviderConfig](interfaces/CacheProviderConfig.md) diff --git a/docs/guide/reference/api/interfaces/CacheContentTypeConfigInput.md b/docs/guide/reference/api/interfaces/CacheContentTypeConfigInput.md index a45009d..dc7c426 100644 --- a/docs/guide/reference/api/interfaces/CacheContentTypeConfigInput.md +++ b/docs/guide/reference/api/interfaces/CacheContentTypeConfigInput.md @@ -1,17 +1,17 @@ # Interface: CacheContentTypeConfigInput -Defined in: [inputs.ts:35](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L35) +Defined in: [inputs.ts:48](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L48) ## Properties | Property | Type | Defined in | | ------ | ------ | ------ | -| `contentType?` | `string` | [inputs.ts:36](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L36) | -| `hitpass?` | `boolean` \| [`CachePluginHitpass`](../type-aliases/CachePluginHitpass.md) | [inputs.ts:40](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L40) | -| `injectDefaultRoutes?` | `boolean` | [inputs.ts:38](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L38) | -| `keys?` | \| [`CacheKeysConfig`](../classes/CacheKeysConfig.md) \| [`CacheKeysConfigInput`](CacheKeysConfigInput.md) | [inputs.ts:41](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L41) | -| `maxAge?` | `number` \| [`Milliseconds`](../type-aliases/Milliseconds.md) | [inputs.ts:39](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L39) | -| `plugin?` | `string` | [inputs.ts:44](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L44) | -| `relatedContentTypeUid?` | `string`[] | [inputs.ts:43](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L43) | -| `routes?` | \| [`CacheRouteConfig`](../classes/CacheRouteConfig.md)[] \| [`CacheRouteConfigInput`](CacheRouteConfigInput.md)[] | [inputs.ts:42](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L42) | -| `singleType?` | `boolean` | [inputs.ts:37](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L37) | +| `contentType?` | `string` | [inputs.ts:49](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L49) | +| `hitpass?` | `boolean` \| [`CachePluginHitpass`](../type-aliases/CachePluginHitpass.md) | [inputs.ts:53](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L53) | +| `injectDefaultRoutes?` | `boolean` | [inputs.ts:51](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L51) | +| `keys?` | \| [`CacheKeysConfig`](../classes/CacheKeysConfig.md) \| [`CacheKeysConfigInput`](CacheKeysConfigInput.md) | [inputs.ts:54](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L54) | +| `maxAge?` | `number` \| [`Milliseconds`](../type-aliases/Milliseconds.md) | [inputs.ts:52](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L52) | +| `plugin?` | `string` | [inputs.ts:57](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L57) | +| `relatedContentTypeUid?` | `string`[] | [inputs.ts:56](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L56) | +| `routes?` | \| [`CacheRouteConfig`](../classes/CacheRouteConfig.md)[] \| [`CacheRouteConfigInput`](CacheRouteConfigInput.md)[] | [inputs.ts:55](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L55) | +| `singleType?` | `boolean` | [inputs.ts:50](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L50) | diff --git a/docs/guide/reference/api/interfaces/CacheControlConfigInput.md b/docs/guide/reference/api/interfaces/CacheControlConfigInput.md new file mode 100644 index 0000000..f114405 --- /dev/null +++ b/docs/guide/reference/api/interfaces/CacheControlConfigInput.md @@ -0,0 +1,19 @@ +# Interface: CacheControlConfigInput + +Defined in: [inputs.ts:21](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L21) + +The shapes a user may write in config/plugins. + +Deliberately separate from the resolved classes: what someone writes is +partial and loosely typed, what the plugin runs on is complete. Conflating +the two is how `maxAge` ended up defaulting to the boolean `true` in one +constructor while being documented as milliseconds everywhere else. + +## Properties + +| Property | Type | Description | Defined in | +| ------ | ------ | ------ | ------ | +| `enabled?` | `boolean` | - | [inputs.ts:22](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L22) | +| `maxAge?` | \| `number` \| `"none"` \| [`Milliseconds`](../type-aliases/Milliseconds.md) \| `"config"` | 'none' omits max-age, 'config' uses the route's maxAge, a number overrides it. The number is milliseconds - see CacheControlConfig. | [inputs.ts:27](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L27) | +| `scope?` | `"public"` \| `"private"` | - | [inputs.ts:28](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L28) | +| `staleWhileRevalidate?` | `number` \| [`Milliseconds`](../type-aliases/Milliseconds.md) | Milliseconds, or null/undefined to omit the directive. | [inputs.ts:30](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L30) | diff --git a/docs/guide/reference/api/interfaces/CacheKeysConfigInput.md b/docs/guide/reference/api/interfaces/CacheKeysConfigInput.md index 1a5e25b..8c2a5a7 100644 --- a/docs/guide/reference/api/interfaces/CacheKeysConfigInput.md +++ b/docs/guide/reference/api/interfaces/CacheKeysConfigInput.md @@ -1,18 +1,11 @@ # Interface: CacheKeysConfigInput -Defined in: [inputs.ts:20](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L20) - -The shapes a user may write in config/plugins. - -Deliberately separate from the resolved classes: what someone writes is -partial and loosely typed, what the plugin runs on is complete. Conflating -the two is how `maxAge` ended up defaulting to the boolean `true` in one -constructor while being documented as milliseconds everywhere else. +Defined in: [inputs.ts:33](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L33) ## Properties | Property | Type | Defined in | | ------ | ------ | ------ | -| `useAuth?` | `boolean` | [inputs.ts:23](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L23) | -| `useHeaders?` | `string`[] | [inputs.ts:21](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L21) | -| `useQueryParams?` | `boolean` \| `string`[] | [inputs.ts:22](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L22) | +| `useAuth?` | `boolean` | [inputs.ts:36](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L36) | +| `useHeaders?` | `string`[] | [inputs.ts:34](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L34) | +| `useQueryParams?` | `boolean` \| `string`[] | [inputs.ts:35](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L35) | diff --git a/docs/guide/reference/api/interfaces/CachePluginStrategyInput.md b/docs/guide/reference/api/interfaces/CachePluginStrategyInput.md index e424ea2..c8e67ab 100644 --- a/docs/guide/reference/api/interfaces/CachePluginStrategyInput.md +++ b/docs/guide/reference/api/interfaces/CachePluginStrategyInput.md @@ -1,21 +1,22 @@ # Interface: CachePluginStrategyInput -Defined in: [inputs.ts:47](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L47) +Defined in: [inputs.ts:60](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L60) ## Properties | Property | Type | Description | Defined in | | ------ | ------ | ------ | ------ | -| `clearRelatedCache?` | `boolean` | - | [inputs.ts:55](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L55) | -| `contentTypes?` | \| ( \| `string` \| [`CacheContentTypeConfigInput`](CacheContentTypeConfigInput.md))[] \| [`CacheContentTypeConfig`](../classes/CacheContentTypeConfig.md)[] | - | [inputs.ts:58](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L58) | -| `debug?` | `boolean` | - | [inputs.ts:48](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L48) | -| `enableAdminCTBMiddleware?` | `boolean` | - | [inputs.ts:51](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L51) | -| `enableContentApiPurge?` | `boolean` | - | [inputs.ts:53](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L53) | -| `enableDocumentServiceMiddleware?` | `boolean` | - | [inputs.ts:52](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L52) | -| `enableEtag?` | `boolean` | - | [inputs.ts:49](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L49) | -| `enableXCacheHeaders?` | `boolean` | - | [inputs.ts:50](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L50) | -| `hitpass?` | `boolean` \| [`CachePluginHitpass`](../type-aliases/CachePluginHitpass.md) | The default hitpass for every content type and route that does not set its own. The plugin ships one that bypasses the cache for any request carrying an authorization or cookie header. Note this is consumed during resolution and deliberately not carried on the resolved CachePluginStrategy: by the time resolution finishes, every route holds its own effective hitpass, so a copy on the strategy could only disagree with them. | [inputs.ts:70](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L70) | -| `keys?` | \| [`CacheKeysConfig`](../classes/CacheKeysConfig.md) \| [`CacheKeysConfigInput`](CacheKeysConfigInput.md) | - | [inputs.ts:59](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L59) | -| `keysPrefix?` | `string` | - | [inputs.ts:57](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L57) | -| `maxAge?` | `number` \| [`Milliseconds`](../type-aliases/Milliseconds.md) | - | [inputs.ts:56](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L56) | -| `resetOnStartup?` | `boolean` | - | [inputs.ts:54](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L54) | +| `cacheControl?` | \| [`CacheControlConfig`](../classes/CacheControlConfig.md) \| [`CacheControlConfigInput`](CacheControlConfigInput.md) | Whether to tell the caller about the caching, by emitting a Cache-Control header on responses this plugin cached. Off unless asked for. **See** https://github.com/strapi-community/plugin-rest-cache/issues/175 | [inputs.ts:79](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L79) | +| `clearRelatedCache?` | `boolean` | - | [inputs.ts:68](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L68) | +| `contentTypes?` | \| ( \| `string` \| [`CacheContentTypeConfigInput`](CacheContentTypeConfigInput.md))[] \| [`CacheContentTypeConfig`](../classes/CacheContentTypeConfig.md)[] | - | [inputs.ts:71](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L71) | +| `debug?` | `boolean` | - | [inputs.ts:61](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L61) | +| `enableAdminCTBMiddleware?` | `boolean` | - | [inputs.ts:64](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L64) | +| `enableContentApiPurge?` | `boolean` | - | [inputs.ts:66](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L66) | +| `enableDocumentServiceMiddleware?` | `boolean` | - | [inputs.ts:65](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L65) | +| `enableEtag?` | `boolean` | - | [inputs.ts:62](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L62) | +| `enableXCacheHeaders?` | `boolean` | - | [inputs.ts:63](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L63) | +| `hitpass?` | `boolean` \| [`CachePluginHitpass`](../type-aliases/CachePluginHitpass.md) | The default hitpass for every content type and route that does not set its own. The plugin ships one that bypasses the cache for any request carrying an authorization or cookie header. Note this is consumed during resolution and deliberately not carried on the resolved CachePluginStrategy: by the time resolution finishes, every route holds its own effective hitpass, so a copy on the strategy could only disagree with them. | [inputs.ts:90](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L90) | +| `keys?` | \| [`CacheKeysConfig`](../classes/CacheKeysConfig.md) \| [`CacheKeysConfigInput`](CacheKeysConfigInput.md) | - | [inputs.ts:72](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L72) | +| `keysPrefix?` | `string` | - | [inputs.ts:70](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L70) | +| `maxAge?` | `number` \| [`Milliseconds`](../type-aliases/Milliseconds.md) | - | [inputs.ts:69](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L69) | +| `resetOnStartup?` | `boolean` | - | [inputs.ts:67](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L67) | diff --git a/docs/guide/reference/api/interfaces/CacheRouteConfigInput.md b/docs/guide/reference/api/interfaces/CacheRouteConfigInput.md index c53f729..1374cbf 100644 --- a/docs/guide/reference/api/interfaces/CacheRouteConfigInput.md +++ b/docs/guide/reference/api/interfaces/CacheRouteConfigInput.md @@ -1,14 +1,14 @@ # Interface: CacheRouteConfigInput -Defined in: [inputs.ts:26](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L26) +Defined in: [inputs.ts:39](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L39) ## Properties | Property | Type | Defined in | | ------ | ------ | ------ | -| `hitpass?` | `boolean` \| [`CachePluginHitpass`](../type-aliases/CachePluginHitpass.md) | [inputs.ts:31](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L31) | -| `keys?` | \| [`CacheKeysConfig`](../classes/CacheKeysConfig.md) \| [`CacheKeysConfigInput`](CacheKeysConfigInput.md) | [inputs.ts:32](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L32) | -| `maxAge?` | `number` \| [`Milliseconds`](../type-aliases/Milliseconds.md) | [inputs.ts:30](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L30) | -| `method?` | [`HttpMethod`](../type-aliases/HttpMethod.md) | [inputs.ts:28](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L28) | -| `paramNames?` | `string`[] | [inputs.ts:29](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L29) | -| `path?` | `string` | [inputs.ts:27](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L27) | +| `hitpass?` | `boolean` \| [`CachePluginHitpass`](../type-aliases/CachePluginHitpass.md) | [inputs.ts:44](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L44) | +| `keys?` | \| [`CacheKeysConfig`](../classes/CacheKeysConfig.md) \| [`CacheKeysConfigInput`](CacheKeysConfigInput.md) | [inputs.ts:45](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L45) | +| `maxAge?` | `number` \| [`Milliseconds`](../type-aliases/Milliseconds.md) | [inputs.ts:43](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L43) | +| `method?` | [`HttpMethod`](../type-aliases/HttpMethod.md) | [inputs.ts:41](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L41) | +| `paramNames?` | `string`[] | [inputs.ts:42](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L42) | +| `path?` | `string` | [inputs.ts:40](https://github.com/strapi-community/plugin-rest-cache/blob/main/packages/plugin-rest-cache/server/src/types/inputs.ts#L40) | diff --git a/docs/guide/reference/config.md b/docs/guide/reference/config.md index c21b31b..931c4e6 100644 --- a/docs/guide/reference/config.md +++ b/docs/guide/reference/config.md @@ -81,6 +81,7 @@ What to cache, for how long, and how to key it. | `keysPrefix` | `string` | `""` | Prefixes every stored key, so the cache can share a keyspace with other consumers. If your Redis uses a `keyPrefix`, match it here. | | `enableEtag` | `boolean` | `false` | Emit an `ETag` and answer `304 Not Modified` when it matches. | | `enableXCacheHeaders` | `boolean` | `false` | Emit `X-Cache: HIT \| MISS \| HITPASS`. Useful in development and for debugging a CDN in front. | +| `cacheControl` | `object` | disabled — see [cacheControl](#cachecontrol) | Emit a `Cache-Control` header on responses this plugin cached. | | `enableDocumentServiceMiddleware` | `boolean` | `true` | Invalidate from the document service rather than from HTTP routes. See [Invalidation](../invalidation/index.md). | | `enableContentApiPurge` | `boolean` | `false` | Expose `POST /api/rest-cache/purge`. Off by default; see [Purging](../invalidation/purging.md). | | `enableAdminCTBMiddleware` | `boolean` | `true` | Inject purge middleware into the content-manager's admin routes. Superseded by `enableDocumentServiceMiddleware` and ignored while that is on. | @@ -158,6 +159,111 @@ The server logs a warning at boot when a content type sets `hitpass: false` without `keys.useAuth`. ::: +### cacheControl + + + +Tells the caller about the caching, by putting a `Cache-Control` header on +responses this plugin served from, or just wrote to, its cache. Off by default, +and worth leaving off until you have read the caveat below. + +Design and original implementation by +[@pinkasey](https://github.com/pinkasey) in +[#96](https://github.com/strapi-community/plugin-rest-cache/pull/96), carried +forward by +[#175](https://github.com/strapi-community/plugin-rest-cache/issues/175). + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `enabled` | `boolean` | `false` | Emit the header at all. | +| `maxAge` | `"none" \| "config" \| number` (ms) | `"config"` | `"none"` omits the `max-age` directive, `"config"` uses the route's resolved `maxAge`, and a number overrides it. **Milliseconds**, like every other duration here; the plugin converts to the seconds the header wants. | +| `scope` | `"public" \| "private"` | `"private"` | `"private"` lets only the end client store the response. `"public"` also allows shared caches such as a CDN. | +| `staleWhileRevalidate` | `number` (ms) \| `null` | `null` | Emits `stale-while-revalidate`, allowing a cache to serve the stale response while it refreshes. `null` omits the directive. | + +:::: code-group + +```js [JavaScript] +// ./config/plugins.js +module.exports = { + "rest-cache": { + config: { + strategy: { + maxAge: 3600000, // one hour, in milliseconds + cacheControl: { + enabled: true, + // The route is cached for an hour, so say so: "max-age=3600". + maxAge: "config", + scope: "public", + staleWhileRevalidate: 60000, // "stale-while-revalidate=60" + }, + contentTypes: ["api::article.article"], + }, + }, + }, +}; +``` + +```ts [TypeScript] +// ./config/plugins.ts +export default { + "rest-cache": { + config: { + strategy: { + maxAge: 3600000, + cacheControl: { + enabled: true, + // Or say something shorter than the server-side lifetime, so a purge + // is felt sooner downstream: "max-age=60". + maxAge: 60000, + scope: "private", + }, + contentTypes: ["api::article.article"], + }, + }, + }, +}; +``` + +:::: + +The header is emitted only for a response the plugin actually cached. It is not +emitted for a `hitpass`, for a response the plugin +[refused to store](#what-is-never-cached), or when the handler already set a +`Cache-Control` of its own — a handler that said `no-store` is giving an +instruction, and it keeps winning. + +::: danger A purge cannot reach a browser or a CDN +This is the whole trade. `POST /api/rest-cache/purge`, the admin button, and +the automatic invalidation on write all empty **this** cache. None of them can +reach a copy held by a browser or a CDN. + +So every `max-age` you emit is a window of guaranteed staleness: publish a +correction, purge everything, and clients that already have the response will +keep serving the old one until their copy expires. Choose a `max-age` you would +be willing to be wrong for — `maxAge: "config"` on an hour-long cache means an +hour. + +If your CDN supports it, purging the CDN belongs in the same operation as +purging Strapi. +::: + +::: warning `scope: "public"` and `keys.useAuth` +Entries keyed per caller hold one caller's response. Advertising those as +`public` would let a shared cache hand user A's data to user B, somewhere the +server cannot see it happen or undo it. + +The plugin does not rely on you getting this right: a route whose +[`keys.useAuth`](#keys) is set is emitted as `private` even when `scope` says +`public`, and the server logs a warning at boot naming the content type. +::: + +::: info Handler-set headers are not replayed from the cache +The plugin caches the response body, not its headers. A handler that sets +`Cache-Control: max-age=10` on a cacheable response keeps that header on the +request it ran for, but a later cache HIT — where the handler does not run — +carries whatever `cacheControl` is configured to emit for that route. +::: + ### hitpass A function taking the Koa context and returning a boolean (or a promise of diff --git a/packages/plugin-rest-cache/server/src/config/index.ts b/packages/plugin-rest-cache/server/src/config/index.ts index d26d802..af2b48c 100644 --- a/packages/plugin-rest-cache/server/src/config/index.ts +++ b/packages/plugin-rest-cache/server/src/config/index.ts @@ -42,6 +42,18 @@ export default { // authentication and must be granted to the caller's role or API token. enableContentApiPurge: false, resetOnStartup: false, + // Emit Cache-Control on responses this plugin cached. Off, and meant to + // stay opt-in rather than merely default-off for upgrades: the header + // hands the caching decision to browsers and CDNs, which no purge can + // reach, so every max-age emitted is a window during which stale content + // is served whatever an admin does. + // See https://github.com/strapi-community/plugin-rest-cache/issues/175 + cacheControl: { + enabled: false, + maxAge: 'config', + scope: 'private', + staleWhileRevalidate: null, + }, clearRelatedCache: true, keysPrefix: '', keys: { diff --git a/packages/plugin-rest-cache/server/src/middlewares/recv.ts b/packages/plugin-rest-cache/server/src/middlewares/recv.ts index 0763869..2b6ae66 100644 --- a/packages/plugin-rest-cache/server/src/middlewares/recv.ts +++ b/packages/plugin-rest-cache/server/src/middlewares/recv.ts @@ -9,6 +9,7 @@ import { etagGenerate } from '../utils/etags/etagGenerate'; import { etagLookup } from '../utils/etags/etagLookup'; import { etagMatch } from '../utils/etags/etagMatch'; import { isCacheable } from '../utils/middlewares/isCacheable'; +import { buildCacheControl } from '../utils/middlewares/buildCacheControl'; import type { CacheRouteConfig } from '../types'; import type { CacheKey } from '../types/common'; import type { RestCachePluginConfig } from '../types/config'; @@ -48,6 +49,37 @@ export default function createRecv( const { hitpass, maxAge, keys } = cacheRouteConfig; const { enableEtag = false, enableXCacheHeaders = false } = strategy; + // Fixed for the life of the route: it is derived from configuration alone. + // null means the feature is off and nothing is ever emitted for this route. + const cacheControl = buildCacheControl(strategy.cacheControl, cacheRouteConfig); + + /** + * Advertise this route's caching to the caller. + * + * Only ever called for a response that is genuinely in, or on its way into, + * the cache. Callers must not invoke it for a hitpass, for a response + * isCacheable refused, or before that verdict is known: a Cache-Control the + * plugin emits early would then be read back by isCacheable as if a handler + * had set it, and `private` would make the plugin refuse to store its own + * response. + * + * @see https://github.com/strapi-community/plugin-rest-cache/issues/175 + */ + const setCacheControl = (ctx: Context): void => { + if (!cacheControl) { + return; + } + + // A handler that stated its own policy has said something specific about + // this response, which outranks a blanket route setting - #133 relies on a + // handler's `no-store` being obeyed rather than replaced. + if (String(ctx.response.get('Cache-Control') || '') !== '') { + return; + } + + ctx.set('Cache-Control', cacheControl); + }; + return async function recv(ctx: Context, next: Next): Promise { // hash const cacheKey = generateCacheKey(ctx, keys); @@ -68,6 +100,11 @@ export default function createRecv( ctx.set('X-Cache', 'HIT'); } + // The entry is in the cache - an ETag is only stored alongside a body + // that was stored - so the freshness we advertise is real. A 304 may + // carry Cache-Control to refresh what the client already holds. + setCacheControl(ctx); + // etag match -> send HTTP 304 Not Modified ctx.body = null; ctx.status = 304; @@ -94,6 +131,8 @@ export default function createRecv( ctx.set('ETag', `"${etagCached}"`); } + setCacheControl(ctx); + ctx.status = 200; ctx.body = cacheEntry; return; @@ -115,6 +154,12 @@ export default function createRecv( ctx.set('X-Cache', 'MISS'); } + // Deliberately no Cache-Control here. We borrowed a body from the + // request that went to the origin and never saw its response headers, + // so we cannot know whether that request's response was stored or + // refused by isCacheable. Staying quiet costs one client one + // revalidation; guessing wrong tells a browser to cache something the + // plugin itself declined to. ctx.status = shared.status; ctx.body = shared.body; return; @@ -186,6 +231,10 @@ export default function createRecv( return; } + // Only now, past the verdict: this response is going into the cache, and + // isCacheable has already read whatever Cache-Control the handler set. + setCacheControl(ctx); + { const writes: Array> = []; diff --git a/packages/plugin-rest-cache/server/src/types/CacheControlConfig.ts b/packages/plugin-rest-cache/server/src/types/CacheControlConfig.ts new file mode 100644 index 0000000..c2de315 --- /dev/null +++ b/packages/plugin-rest-cache/server/src/types/CacheControlConfig.ts @@ -0,0 +1,81 @@ +import { ms } from './common'; +import type { Milliseconds } from './common'; +import type { CacheControlConfigInput } from './inputs'; + +/** + * What `Cache-Control` to put on a response this plugin cached. + * + * Design and original implementation by `@pinkasey` in + * https://github.com/strapi-community/plugin-rest-cache/pull/96, which targeted + * Strapi 4 and can no longer be rebased. Carried forward by + * https://github.com/strapi-community/plugin-rest-cache/issues/175. + * + * Simplified from #96's two nested types - a `CacheControlHeaderConfig` + * wrapping a `CacheControlResponseHeaderConfig` - into the one flat block + * below, because only the response direction is implemented here. Honouring an + * incoming request `Cache-Control` is still open, and a wrapper whose only + * member today is `response` buys nothing while making every user write + * `cacheControl.response.maxAge`. Should the request direction land, it can add + * a `cacheControl.request` block without changing any of these names. + * + * #96's `CacheControlResponseMaxAge` enum - NONE / CONFIG / a number - is kept, + * as the union on `maxAge`. That is the part carrying the meaning: "say + * nothing", "say what the route is actually cached for", or "say this instead". + * + * Off by default and meant to stay opt-in: the header moves caching to browsers + * and CDNs, where a purge cannot reach it, so every emitted `max-age` is a + * window of guaranteed staleness that an operator has to choose knowingly. + */ +export class CacheControlConfig { + /** Emit the header at all. */ + enabled: boolean = false; + + /** + * `none` omits the `max-age` directive, `config` takes the route's resolved + * `maxAge`, and a number overrides it. + * + * That number is MILLISECONDS, like every other duration in this plugin, even + * though the directive it ends up in is seconds. A single field that meant + * seconds while `maxAge`, `ttl` and `staleWhileRevalidate` meant milliseconds + * is precisely the ambiguity behind + * https://github.com/strapi-community/plugin-rest-cache/issues/126. The one + * conversion lives in buildCacheControl. + */ + maxAge: 'none' | 'config' | Milliseconds = 'config'; + + /** + * `private` means only the end client may store the response; `public` also + * allows shared caches such as a CDN. + * + * Defaults to `private`, the answer that cannot leak: a wrongly-public + * response is served to the wrong person by a cache the server does not own. + * `public` is downgraded to `private` on any route whose keys identify the + * caller - see buildCacheControl. + */ + scope: 'public' | 'private' = 'private'; + + /** + * How long a cache may keep serving the stale response while it refreshes, + * or null to omit the directive. + * + * Milliseconds, for the same reason as `maxAge` above. + */ + staleWhileRevalidate: Milliseconds | null = null; + + constructor(options: CacheControlConfigInput = {}) { + const { + enabled = false, + maxAge = 'config', + scope = 'private', + staleWhileRevalidate = null, + } = options; + + this.enabled = enabled; + this.maxAge = typeof maxAge === 'number' ? ms(maxAge) : maxAge; + this.scope = scope; + this.staleWhileRevalidate = + staleWhileRevalidate === null || staleWhileRevalidate === undefined + ? null + : ms(staleWhileRevalidate); + } +} diff --git a/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts b/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts index c7b7553..9e39aa8 100644 --- a/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts +++ b/packages/plugin-rest-cache/server/src/types/CachePluginStrategy.ts @@ -1,4 +1,5 @@ import { CacheKeysConfig } from './CacheKeysConfig'; +import { CacheControlConfig } from './CacheControlConfig'; import { CacheContentTypeConfig } from './CacheContentTypeConfig'; import { ms } from './common'; import type { Milliseconds } from './common'; @@ -31,6 +32,13 @@ export class CachePluginStrategy { keys: CacheKeysConfig; + /** + * Whether cached responses advertise their caching downstream. + * + * @see https://github.com/strapi-community/plugin-rest-cache/issues/175 + */ + cacheControl: CacheControlConfig; + constructor(options: CachePluginStrategyInput = {}) { const { debug = false, @@ -45,6 +53,7 @@ export class CachePluginStrategy { keysPrefix = '', contentTypes = [], keys = new CacheKeysConfig(), + cacheControl = new CacheControlConfig(), } = options; this.debug = debug; @@ -59,5 +68,9 @@ export class CachePluginStrategy { this.keysPrefix = keysPrefix; this.contentTypes = contentTypes as CacheContentTypeConfig[]; this.keys = keys instanceof CacheKeysConfig ? keys : new CacheKeysConfig(keys); + this.cacheControl = + cacheControl instanceof CacheControlConfig + ? cacheControl + : new CacheControlConfig(cacheControl); } } diff --git a/packages/plugin-rest-cache/server/src/types/index.ts b/packages/plugin-rest-cache/server/src/types/index.ts index 34361e0..63d04b8 100644 --- a/packages/plugin-rest-cache/server/src/types/index.ts +++ b/packages/plugin-rest-cache/server/src/types/index.ts @@ -3,6 +3,7 @@ export { CacheRouteConfig } from './CacheRouteConfig'; export { CacheProvider } from './CacheProvider'; export { CacheContentTypeConfig } from './CacheContentTypeConfig'; export { CacheKeysConfig } from './CacheKeysConfig'; +export { CacheControlConfig } from './CacheControlConfig'; export * from './common'; export type * from './inputs'; diff --git a/packages/plugin-rest-cache/server/src/types/inputs.ts b/packages/plugin-rest-cache/server/src/types/inputs.ts index c75f79e..504bff8 100644 --- a/packages/plugin-rest-cache/server/src/types/inputs.ts +++ b/packages/plugin-rest-cache/server/src/types/inputs.ts @@ -4,6 +4,7 @@ import type { HttpMethod, Milliseconds, } from './common'; +import type { CacheControlConfig } from './CacheControlConfig'; import type { CacheKeysConfig } from './CacheKeysConfig'; import type { CacheRouteConfig } from './CacheRouteConfig'; import type { CacheContentTypeConfig } from './CacheContentTypeConfig'; @@ -17,6 +18,18 @@ import type { CacheContentTypeConfig } from './CacheContentTypeConfig'; * constructor while being documented as milliseconds everywhere else. */ +export interface CacheControlConfigInput { + enabled?: boolean; + /** + * 'none' omits max-age, 'config' uses the route's maxAge, a number overrides + * it. The number is milliseconds - see CacheControlConfig. + */ + maxAge?: 'none' | 'config' | Milliseconds | number; + scope?: 'public' | 'private'; + /** Milliseconds, or null/undefined to omit the directive. */ + staleWhileRevalidate?: Milliseconds | number | null; +} + export interface CacheKeysConfigInput { useHeaders?: string[]; useQueryParams?: boolean | string[]; @@ -57,6 +70,13 @@ export interface CachePluginStrategyInput { keysPrefix?: string; contentTypes?: Array | CacheContentTypeConfig[]; keys?: CacheKeysConfig | CacheKeysConfigInput; + /** + * Whether to tell the caller about the caching, by emitting a Cache-Control + * header on responses this plugin cached. Off unless asked for. + * + * @see https://github.com/strapi-community/plugin-rest-cache/issues/175 + */ + cacheControl?: CacheControlConfig | CacheControlConfigInput; /** * The default hitpass for every content type and route that does not set its * own. The plugin ships one that bypasses the cache for any request carrying diff --git a/packages/plugin-rest-cache/server/src/utils/config/resolveUserStrategy.ts b/packages/plugin-rest-cache/server/src/utils/config/resolveUserStrategy.ts index 19cac06..c3afceb 100644 --- a/packages/plugin-rest-cache/server/src/utils/config/resolveUserStrategy.ts +++ b/packages/plugin-rest-cache/server/src/utils/config/resolveUserStrategy.ts @@ -248,6 +248,34 @@ export const resolveUserStrategy = function ( } } + // `cacheControl.scope: "public"` invites shared caches to reuse a response for + // anybody, which contradicts keying entries per caller. The emitter downgrades + // those routes to "private" itself - this is not something an operator gets to + // misconfigure into a leak - but say so at boot, because an unannounced + // downgrade otherwise shows up as a CDN that inexplicably declines to cache. + // See https://github.com/strapi-community/plugin-rest-cache/issues/175 + if ( + userOptions.cacheControl?.enabled && + userOptions.cacheControl.scope === 'public' + ) { + for (const cacheConfig of cacheConfigs) { + const keyedPerCaller = cacheConfig.routes.filter( + (route) => route.keys?.useAuth === true + ); + + if (!keyedPerCaller.length) { + continue; + } + + strapi.log.warn( + `REST Cache: cacheControl.scope is "public" but "${cacheConfig.contentType}" keys entries per caller ` + + `on ${keyedPerCaller.length} route(s) (keys.useAuth). Those responses are caller-specific, and a shared ` + + "cache told they are public could serve one caller's response to another. Emitting \"private\" for them " + + 'instead.' + ); + } + } + return deepFreeze( new CachePluginStrategy({ ...userOptions, diff --git a/packages/plugin-rest-cache/server/src/utils/middlewares/buildCacheControl.ts b/packages/plugin-rest-cache/server/src/utils/middlewares/buildCacheControl.ts new file mode 100644 index 0000000..539fc91 --- /dev/null +++ b/packages/plugin-rest-cache/server/src/utils/middlewares/buildCacheControl.ts @@ -0,0 +1,70 @@ +import type { CacheControlConfig } from '../../types/CacheControlConfig'; +import type { CacheRouteConfig } from '../../types/CacheRouteConfig'; +import type { Milliseconds } from '../../types/common'; + +/** + * Milliseconds to whole seconds. + * + * The single conversion in this plugin, and it exists only because + * `Cache-Control` is defined in seconds while every duration here is + * milliseconds. Rounding down rather than to nearest: a fractional delta-seconds + * is invalid per RFC 9111, and erring short means a client refreshes a moment + * early instead of serving stale content a moment too long. + * + * The direction is the whole point. Multiplying instead of dividing turns a + * configured hour into 114 years of browser cache, which is the same class of + * mistake as https://github.com/strapi-community/plugin-rest-cache/issues/126, + * except that it lands in caches this server cannot purge. + */ +const toSeconds = (value: Milliseconds): number => Math.floor(value / 1000); + +/** + * The Cache-Control value for a route, or null if the plugin should stay quiet. + * + * Depends only on configuration, so recv computes it once per route at + * registration rather than per request. Whether it is actually *applied* is a + * per-request decision recv makes - see the callers there, which refuse for + * anything that bypassed the cache, was not stored, or already carries a + * header the handler set. + * + * @see https://github.com/strapi-community/plugin-rest-cache/issues/175 + */ +export const buildCacheControl = function ( + config: CacheControlConfig | undefined, + cacheRouteConfig: CacheRouteConfig +): string | null { + if (!config?.enabled) { + return null; + } + + const directives: string[] = []; + + // An entry keyed per caller holds one caller's response. Telling a shared + // cache it may serve that to anybody is how a CDN hands user A's data to + // user B - a leak the server cannot see happening, let alone undo. Downgrade + // rather than trust the configuration: the operator who wrote `public` at the + // strategy level cannot be expected to re-check it against every route's + // keys. register.ts warns at boot when this applies. + // See https://github.com/strapi-community/plugin-rest-cache/issues/113 + const scope = + config.scope === 'public' && cacheRouteConfig.keys?.useAuth === true + ? 'private' + : config.scope; + + directives.push(scope); + + if (config.maxAge !== 'none') { + const maxAge = + config.maxAge === 'config' ? cacheRouteConfig.maxAge : config.maxAge; + + directives.push(`max-age=${toSeconds(maxAge)}`); + } + + if (config.staleWhileRevalidate !== null) { + directives.push( + `stale-while-revalidate=${toSeconds(config.staleWhileRevalidate)}` + ); + } + + return directives.join(', '); +}; diff --git a/shared/config/cache-strategy.js b/shared/config/cache-strategy.js index 3bc7967..d55d304 100644 --- a/shared/config/cache-strategy.js +++ b/shared/config/cache-strategy.js @@ -26,6 +26,20 @@ module.exports = ({ env }) => ({ Boolean( ctx.request.headers.authorization || ctx.request.headers.cookie ), + // Emit Cache-Control on responses this plugin cached. Off by default, as it + // ships. See https://github.com/strapi-community/plugin-rest-cache/issues/175 + cacheControl: { + enabled: env.bool("CACHE_CONTROL_ENABLED", false), + // "none" | "config" | a number of MILLISECONDS, like every other duration + // in this plugin. + maxAge: /^\d+$/.test(env("CACHE_CONTROL_MAX_AGE", "config")) + ? env.int("CACHE_CONTROL_MAX_AGE", 3600000) + : env("CACHE_CONTROL_MAX_AGE", "config"), + scope: env("CACHE_CONTROL_SCOPE", "private"), + // Milliseconds. 0 means omit the directive. + staleWhileRevalidate: + env.int("CACHE_CONTROL_STALE_WHILE_REVALIDATE", 0) || null, + }, contentTypes: [ "api::article.article", // A content type whose singular name ("editor") differs from its parent @@ -51,6 +65,13 @@ module.exports = ({ env }) => ({ { path: "/api/categories/probe/raw", method: "GET" }, { path: "/api/categories/probe/stream", method: "GET" }, { path: "/api/categories/probe/with-cookie", method: "GET" }, + { + // The handler sets its own Cache-Control, chosen by ?value=, so the + // query string has to be part of the key here. + path: "/api/categories/probe/cache-control", + method: "GET", + keys: { useQueryParams: true, useHeaders: [], useAuth: true }, + }, { path: "/api/categories/slug/:slug+", keys: { diff --git a/shared/src/api/category/controllers/category.js b/shared/src/api/category/controllers/category.js index 889a27c..507c57a 100644 --- a/shared/src/api/category/controllers/category.js +++ b/shared/src/api/category/controllers/category.js @@ -33,6 +33,16 @@ module.exports = createCoreController( ctx.body = { cookie: true }; }, + /** + * A handler that states its own caching policy. `?value=` picks it, so one + * fixture covers both a directive that stops the plugin storing the + * response (`no-store`) and one that does not (`max-age=99`). + */ + async withCacheControl(ctx) { + ctx.set("Cache-Control", String(ctx.query.value || "no-store")); + ctx.body = { handlerSetCacheControl: true }; + }, + async findBySlug(ctx) { const { slug } = ctx.params; const { query } = ctx; diff --git a/shared/src/api/category/routes/custom-category.js b/shared/src/api/category/routes/custom-category.js index 0bcbeec..097fe0a 100644 --- a/shared/src/api/category/routes/custom-category.js +++ b/shared/src/api/category/routes/custom-category.js @@ -24,5 +24,13 @@ module.exports = { path: "/categories/probe/with-cookie", handler: "category.withCookie", }, + // Fixture for a handler that sets Cache-Control itself, which the plugin + // must never overwrite. See + // https://github.com/strapi-community/plugin-rest-cache/issues/175 + { + method: "GET", + path: "/categories/probe/cache-control", + handler: "category.withCacheControl", + }, ], }; diff --git a/shared/src/bootstrap.js b/shared/src/bootstrap.js index 31e8114..0573fbb 100644 --- a/shared/src/bootstrap.js +++ b/shared/src/bootstrap.js @@ -186,7 +186,15 @@ async function importSeedData() { global: ["find"], homepage: ["find"], article: ["find", "findOne"], - category: ["find", "findBySlug", "findOne", "raw", "stream", "withCookie"], + category: [ + "find", + "findBySlug", + "findOne", + "raw", + "stream", + "withCookie", + "withCacheControl", + ], writer: ["find", "findOne"], "api::writer.editor": ["find", "findOne"], }); diff --git a/shared/tests/flag-cache-control.test.js b/shared/tests/flag-cache-control.test.js new file mode 100644 index 0000000..95d719a --- /dev/null +++ b/shared/tests/flag-cache-control.test.js @@ -0,0 +1,207 @@ +"use strict"; + +/** + * Coverage for strategy.cacheControl - emitting Cache-Control on responses this + * plugin cached. + * + * Design and original implementation by @pinkasey in + * https://github.com/strapi-community/plugin-rest-cache/pull/96, carried + * forward by + * https://github.com/strapi-community/plugin-rest-cache/issues/175. + * + * The assertions that matter are the ones about what is *not* emitted. A + * Cache-Control on the wrong response is worse than none at all: it moves the + * caching decision to a browser or CDN, where a purge cannot reach it, so the + * mistake is served for the whole max-age no matter what an admin does. + * + * Each block boots its own Strapi instance because the flag is read at + * register time. + */ + +const { setup, teardown, agent } = require("./helpers/strapi"); + +jest.setTimeout(90000); + +process.env.STRAPI_DISABLE_UPDATE_NOTIFICATION = true; +process.env.STRAPI_HIDE_STARTUP_MESSAGE = true; +process.env.STRAPI_TELEMETRY_DISABLED = true; + +const store = () => strapi.plugin("rest-cache").service("cacheStore"); + +const cacheControlOf = (res) => res.headers["cache-control"]; + +describe("cacheControl: disabled (the shipped default)", () => { + beforeAll(async () => { + process.env.KEYS_PREFIX = undefined; + await setup(); + }); + afterAll(async () => await teardown()); + + beforeEach(() => store().reset()); + + it("emits no Cache-Control on a cached response", async () => { + const first = await agent().get("/api/articles"); + const second = await agent().get("/api/articles"); + + // The response really is coming from the cache, so the absence of the + // header below is about the flag and not about a cache miss. + expect(first.get("x-cache")).toBe("MISS"); + expect(second.get("x-cache")).toBe("HIT"); + + expect(cacheControlOf(first)).toBeUndefined(); + expect(cacheControlOf(second)).toBeUndefined(); + }); +}); + +describe("cacheControl: enabled", () => { + beforeAll(async () => { + process.env.KEYS_PREFIX = undefined; + process.env.CACHE_CONTROL_ENABLED = "true"; + await setup(); + }); + afterAll(async () => { + delete process.env.CACHE_CONTROL_ENABLED; + await teardown(); + }); + + beforeEach(() => store().reset()); + + describe("max-age is the route's maxAge, in seconds", () => { + it("turns 3600000ms into max-age=3600 on a HIT", async () => { + const first = await agent().get("/api/articles"); + const second = await agent().get("/api/articles"); + + expect(second.get("x-cache")).toBe("HIT"); + + // Milliseconds to seconds, not the other way round. Converting the wrong + // way here yields max-age=3600000000, i.e. 114 years. + // See https://github.com/strapi-community/plugin-rest-cache/issues/126 + expect(cacheControlOf(second)).toBe("private, max-age=3600"); + expect(cacheControlOf(first)).toBe("private, max-age=3600"); + }); + + it("uses the route's own maxAge, not the strategy default", async () => { + // /api/categories/slug/:slug+ is configured with maxAge: 18000. + const first = await agent().get("/api/categories/slug/news"); + const second = await agent().get("/api/categories/slug/news"); + + expect(second.get("x-cache")).toBe("HIT"); + expect(cacheControlOf(second)).toBe("private, max-age=18"); + expect(cacheControlOf(first)).toBe("private, max-age=18"); + }); + }); + + it("emits nothing for a HITPASS response", async () => { + // The default hitpass bypasses anything carrying a cookie. Telling a + // browser to cache a deliberately-uncached response for an hour puts it + // somewhere no purge can reach. + const res = await agent().get("/api/articles").set("Cookie", "sid=abc"); + + expect(res.status).toBe(200); + expect(res.get("x-cache")).toBe("HITPASS"); + expect(cacheControlOf(res)).toBeUndefined(); + }); + + it("emits nothing for a response the plugin refused to store", async () => { + // A streamed body cannot be replayed, so isCacheable refuses it. Nothing + // was cached, so there is nothing to advertise. + const res = await agent().get("/api/categories/probe/stream"); + + expect(res.status).toBe(200); + expect(cacheControlOf(res)).toBeUndefined(); + }); + + describe("a handler that set Cache-Control itself", () => { + it("keeps a handler's no-store", async () => { + const res = await agent().get( + "/api/categories/probe/cache-control?value=no-store" + ); + + expect(res.status).toBe(200); + expect(cacheControlOf(res)).toBe("no-store"); + }); + + it("keeps a handler's directive even when the response is cacheable", async () => { + const res = await agent().get( + "/api/categories/probe/cache-control?value=max-age%3D99" + ); + + expect(res.status).toBe(200); + expect(cacheControlOf(res)).toBe("max-age=99"); + }); + }); +}); + +describe("cacheControl: enabled with scope public", () => { + // The warning is logged once, while Strapi is registering the plugin, so it + // has to be captured around setup(). The playground's log level is "error", + // which would swallow it. + let bootOutput = ""; + + beforeAll(async () => { + process.env.KEYS_PREFIX = undefined; + process.env.CACHE_CONTROL_ENABLED = "true"; + process.env.CACHE_CONTROL_SCOPE = "public"; + process.env.STRAPI_LOG_LEVEL = "warn"; + + // Strapi logs through winston, whose console transport writes to + // console._stdout when it exists - under jest that is jest's own stream, + // not process.stdout - so both have to be watched. + const streams = [process.stdout, console._stdout].filter( + (stream, index, all) => stream && all.indexOf(stream) === index + ); + + const spies = streams.map((stream) => { + const write = stream.write.bind(stream); + + return jest.spyOn(stream, "write").mockImplementation((chunk, ...rest) => { + bootOutput += String(chunk); + return write(chunk, ...rest); + }); + }); + + try { + await setup(); + } finally { + spies.forEach((spy) => spy.mockRestore()); + } + }); + afterAll(async () => { + delete process.env.CACHE_CONTROL_ENABLED; + delete process.env.CACHE_CONTROL_SCOPE; + delete process.env.STRAPI_LOG_LEVEL; + await teardown(); + }); + + beforeEach(() => store().reset()); + + it("never says public on a route keyed per caller", async () => { + // api::category.category is configured with keys.useAuth, so its entries + // are caller-specific. A shared "public" would let a CDN hand one caller's + // response to another. + // See https://github.com/strapi-community/plugin-rest-cache/issues/113 + const first = await agent().get("/api/categories"); + const second = await agent().get("/api/categories"); + + expect(second.get("x-cache")).toBe("HIT"); + expect(cacheControlOf(second)).not.toContain("public"); + expect(cacheControlOf(second)).toBe("private, max-age=3600"); + expect(cacheControlOf(first)).toBe("private, max-age=3600"); + }); + + it("says at boot that it downgraded the directive", () => { + // The downgrade is silent on the wire, and a CDN that quietly stops caching + // is a miserable thing to debug from the other end. + expect(bootOutput).toContain("cacheControl.scope"); + expect(bootOutput).toContain("api::category.category"); + }); + + it("still says public where entries are shared", async () => { + const first = await agent().get("/api/articles"); + const second = await agent().get("/api/articles"); + + expect(second.get("x-cache")).toBe("HIT"); + expect(cacheControlOf(second)).toBe("public, max-age=3600"); + expect(cacheControlOf(first)).toBe("public, max-age=3600"); + }); +});