Skip to content

feat: emit Cache-Control headers on cached responses - #201

Merged
derrickmehaffy merged 1 commit into
mainfrom
feat/cache-control
Aug 14, 2026
Merged

feat: emit Cache-Control headers on cached responses#201
derrickmehaffy merged 1 commit into
mainfrom
feat/cache-control

Conversation

@derrickmehaffy

Copy link
Copy Markdown
Contributor

Closes #175.

Config

Opt-in, off by default, under strategy.cacheControl:

cacheControl: {
  enabled: false,
  maxAge: 'config',            // 'none' | 'config' | <milliseconds>
  scope: 'private',
  staleWhileRevalidate: null,  // <milliseconds> | null
}

Keeps the CacheControlResponseMaxAge union from @pinkasey's #96 — that is the
part carrying meaning — but flattens the CacheControlHeaderConfig.response
nesting, since only the response direction is implemented and a wrapper with one
member would only make everyone type cacheControl.response.maxAge. Credit is
recorded in the class comment, the docs, and the test header.

Two deliberate calls

Every duration is milliseconds, branded, including the explicit maxAge
number and staleWhileRevalidate. A seconds-valued field sitting next to
maxAge/ttl is the exact ambiguity behind #126, where a configured hour
became 41.7 days. The single ms→s conversion lives in toSeconds() in
buildCacheControl.ts and floors to an integer.

scope defaults to private, and public is downgraded to private on
any route with keys.useAuth — enforced, not merely documented. A per-caller
cache key marked public invites a shared proxy to serve one user's response to
another. resolveUserStrategy also warns at boot naming the content type.

Where it emits

On a HIT, on an ETag 304, and on a MISS after isCacheable says the
response will be stored. Not on hitpass, not on a refused response, not on the
coalesced-waiter path (we never saw that response's headers, so we cannot claim
it was stored), and never over a Cache-Control the handler already set.

Tests

10 new e2e tests in shared/tests/flag-cache-control.test.js, each seen red
first. Six failed initially with Expected: "private, max-age=3600" / Received: undefined.

Each guard was then re-reddened against a deliberately naive implementation
(emit straight after next(), no already-set check, no useAuth downgrade):

Guard Failure it produces
emitted after the cacheability verdict Expected: "HIT" / Received: "MISS" — emitting private too early made the plugin refuse to store its own response
handler's no-store kept Expected: "no-store" / Received: "private, max-age=3600"
never public with useAuth Expected substring: not "public" / Received: "public, max-age=3600"
nothing on HITPASS / refused toBeUndefined() / Received: "private, max-age=3600"

Full suite on this branch: 22 suites, 126 tests passed.

Not in scope

  • Honouring an incoming request Cache-Control (the other half of Emit Cache-Control on cached responses #175).
    The flat config leaves room for a cacheControl.request block.
  • Per-content-type / per-route overrides — strategy-level only, though max-age
    still varies per route because 'config' reads the route's resolved maxAge.
  • Replaying a handler's header from cache. The plugin caches the body, not
    headers, so a later HIT carries the configured value. Documented rather than
    changed, since storing headers is a bigger change.

🤖 Generated with Claude Code

Closes #175. Opt-in via strategy.cacheControl, off by default:

  cacheControl: {
    enabled: false,
    maxAge: 'config',              // 'none' | 'config' | <milliseconds>
    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 <noreply@anthropic.com>
@derrickmehaffy derrickmehaffy added the publish-experimental Publish an experimental build of this PR to npm label Aug 14, 2026
@derrickmehaffy derrickmehaffy added publish-experimental Publish an experimental build of this PR to npm and removed publish-experimental Publish an experimental build of this PR to npm labels Aug 14, 2026
@github-actions github-actions Bot removed the publish-experimental Publish an experimental build of this PR to npm label Aug 14, 2026
@derrickmehaffy derrickmehaffy added the publish-experimental Publish an experimental build of this PR to npm label Aug 14, 2026
@github-actions github-actions Bot removed the publish-experimental Publish an experimental build of this PR to npm label Aug 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Experimental build

Version 0.0.0-experimental.ff1018e35130f7a559a29eec63bdfaf182745178
Dist tag experimental
Commit ff1018e
npm install @strapi-community/plugin-rest-cache@0.0.0-experimental.ff1018e35130f7a559a29eec63bdfaf182745178

Install by exact version. The experimental tag moves with every build.

The publish-experimental label has been removed. Add it again to publish another build.

@derrickmehaffy
derrickmehaffy merged commit ee75158 into main Aug 14, 2026
16 of 18 checks passed
This was referenced Aug 14, 2026
@derrickmehaffy
derrickmehaffy deleted the feat/cache-control branch August 14, 2026 05:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Emit Cache-Control on cached responses

1 participant