Skip to content

About

RFC 9111 HTTP caching policy engine with RFC 5861 stale extensions. Decides storability, freshness, Vary matching, revalidation and invalidation for stored request/response pairs. No network code, no dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

http-cache-policy

An RFC 9111 HTTP caching policy engine with the RFC 5861 stale-while-revalidate and stale-if-error extensions. It decides; the caller stores bodies and does the I/O.

import { CachePolicy } from 'http-cache-policy';

const req = { method: 'GET', url: 'https://example.com/a', headers: { accept: 'text/html' } };
const policy = new CachePolicy(req, { status: 200, headers: response.headers });

if (policy.storable()) {
  const d = policy.evaluate(req); // later, for a new request
  // d.action: 'reuse' | 'reuse-and-revalidate' | 'revalidate' | 'forward' | 'unsatisfiable'
  // d.headers: the headers to serve (Age set, Date untouched)
}

Why

http-cache-semantics is the policy library behind got, make-fetch-happen and npm's registry client. Version 4.2.0 (last release 2025-05) has these defects, each reproduced below:

  • Vary values that mean * but are not the exact string * ("*, *", ", *", "* ", "*,") are reused across clients (upstream issue #57, "Vary: * reuse prohibition bypassed by whitespace/list forms and prototype field names").
  • A request max-stale overrides proxy-revalidate, s-maxage and no-cache (upstream issue #56, "max-stale request directive overrides shared-cache reuse prohibitions").
  • No-CaChE is not recognized.
  • Expires values in invalid formats are accepted as valid dates.
  • Date is rewritten when a stored response is served.
  • A fresh must-revalidate response is not reused.

This package implements the same job from the RFC text and is measured against the http-tests/cache-tests suite.

Measured: node bench/repro.ts (after npm install in bench/compare), reuse of a stored response by a following request:

scenario                                       want   hcs 4.2.0  this package
Vary "*, *"                                    false  true (wrong) false
Vary ", *"                                     false  true (wrong) false
Vary "* "                                      false  true (wrong) false
Vary "*,"                                      false  true (wrong) false
Vary "constructor" (prototype name)            true   true       true
Cache-Control "max-age=100, No-CaChE"          false  true (wrong) false
Expires with UTC zone                          false  true (wrong) false
Expires with 1-digit hour                      false  true (wrong) false
Expires with dashes                            false  true (wrong) false
Expires "0"                                    false  false      false
must-revalidate, fresh (max-age=100)           true   false (wrong) true
max-stale on proxy-revalidate (shared)         false  true (wrong) false
max-stale on s-maxage (shared)                 false  true (wrong) false
max-stale on must-revalidate                   false  false      false
max-stale on no-cache                          false  true (wrong) false
max-stale on plain stale response (allowed)    true   true       true
Date preserved on serve                        true   false (wrong) true

The Vary: constructor row is not a defect: a header that is absent from both requests matches, in both libraries. The Set-Cookie part of issue #56 concerns a rule of http-cache-semantics that RFC 9111 (section 7.3) does not contain, so it does not apply here.

Measured: the cache-tests suite through a reverse proxy around each implementation (bench/run.sh, node bench/matrix.mjs). Required tests only; "of decided" is pass / (pass + fail), "of all" also counts tests that could not be run.

Implementation Required pass fail not run of decided of all Optional pass / fail
http-cache-policy (this package) 148 0 1 100.0% 99.3% 87 / 8
undici 8.11.2 interceptors.cache() 125 12 12 91.2% 83.9% 65 / 25
http-cache-semantics 4.2.0 113 16 20 87.6% 75.8% 56 / 36
best-before 0.2.1 (requireSharedDirective: false) 116 15 18 88.5% 77.9% 61 / 29
best-before 0.2.1 (defaults) 30 12 107 71.4% 20.1% 11 / 16
Negative control: forwards everything, caches nothing 19 6 124 76.0% 12.8% 0 / 19
Caddy (public result) 116 4 29 96.7% 77.9% 38 / 54
Apache httpd (public result) 130 8 11 94.2% 87.2% 65 / 24
Traffic Server (public result) 132 14 3 90.4% 88.6% 70 / 20
Varnish (public result) 119 13 17 90.2% 79.9% 45 / 41
Squid (public result) 121 13 15 90.3% 81.2% 56 / 36
Safari (public result) 118 15 4 88.7% 86.1% 56 / 21
Chrome (public result) 117 18 2 86.7% 85.4% 56 / 20
Firefox (public result) 106 17 14 86.2% 77.4% 40 / 37
HAProxy (public result) 88 11 50 88.9% 59.1% 37 / 47
nginx (public result) 99 28 22 78.0% 66.4% 59 / 27
Fastly (public result) 96 32 21 75.0% 64.4% 49 / 34

Excluded: the cdn-cache-control and interim suites, which are not RFC 9111 policy. "Not run" counts tests whose prerequisite test failed and tests whose setup failed.

Notes on reading the table:

  • The suite states that passing it "currently means nothing" by itself. Most required tests check that a cache does not reuse something, so a cache that stores nothing passes many of them; the negative-control row shows this, and why the "of all" column is there. Tests that need an earlier positive behavior to work are reported as not run when that behavior fails.
  • The public results were produced by other people on other versions of the suite and are re-scored here with the suite's own determineTestResult at the pinned commit. Only the shipped result files were used.
  • The proxy around this package is bench/proxy.ts. It stores bodies, forwards requests, slices Range requests out of complete stored bodies and calls CachePolicy for every caching decision. The comparison proxies are in bench/compare.
  • best-before is a layer over the Web Cache API. Node has no Cache API, so bench/compare/proxy-best-before.mjs supplies a small in-memory store with the Vary matching that best-before delegates to the store. Its numbers include that store.
  • @web-widget/shared-cache is built on a fork of http-cache-semantics. node bench/compare/fork-check.mjs shows the fork reusing responses for Vary: "*, *", No-CaChE and an Expires in UTC, so the package was not run through the suite.

Installation

npm install http-cache-policy

No runtime dependencies. Requires Node 20.6 or later.

Usage

Store a response

const policy = new CachePolicy(request, { status, headers }, { shared: true });
if (policy.storable()) {
  cache.set(key, { policy: policy.toObject(), body });
}

toObject() returns JSON-serializable data. CachePolicy.fromObject() restores it. Record the times yourself for accurate ages:

new CachePolicy(request, response, { requestTime, responseTime }); // ms since the epoch

Answer a request

Filter your stored policies by satisfies(request) (URL, method and Vary), pick with CachePolicy.select() (most recent by Date), then ask evaluate():

const policy = CachePolicy.select(candidates, request);
const d = policy?.evaluate(request);
switch (d?.action) {
  case 'reuse': return respond(d.status, d.headers, body);
  case 'reuse-and-revalidate': respond(d.status, d.headers, body); revalidateInBackground(d.revalidation); break;
  case 'revalidate': return sendConditional(d.revalidation);
  case 'unsatisfiable': return respond(504); // only-if-cached
  default: return forward(request);
}

Revalidate

const rv = policy.revalidationRequest(request); // If-None-Match / If-Modified-Since
const r = policy.update({ status: 304, headers }, { requestTime, responseTime });
if (r.updated) store(r.policy); // merged per RFC 9111 3.2

If r.updated is false the 304 does not identify the stored response. Fetch unconditionally. After a successful update, call policy.precondition(clientRequest) to see whether the client's own If-None-Match or If-Modified-Since earns a 304.

Serve stale on error

if (originFailed && policy.canServeStale({ staleIfError: true })) serveStored();
if (originUnreachable && policy.canServeStale({ disconnected: true })) serveStored();

Neither ever overrides must-revalidate, no-cache, or (in a shared cache) proxy-revalidate and s-maxage.

Invalidate after unsafe requests

for (const url of invalidates(request, response)) cache.delete(url);

Reference

new CachePolicy(request, response, options?)

request is { method?, url?, headers? } and response is { status, headers? }. Headers may be a plain object, a Headers object or name/value pairs; repeated lines are combined with , as RFC 9110 5.3 allows.

Option Type Default Description
shared boolean true Shared cache rules. A private cache ignores s-maxage, may store private, and is not limited by Authorization.
ignoreCargoCult boolean false When pre-check and post-check are both present, ignore no-cache, no-store, must-revalidate and Expires. Deviates from RFC 9111.
trustServerDate boolean true When false, the response time replaces the Date header in age and Expires calculations.
heuristicFraction number 0.1 Fraction of the time since Last-Modified used as heuristic lifetime.
maxHeuristicAge number none Cap on the heuristic lifetime in seconds.
requestTime number now Clock value in ms when the request was sent.
responseTime number now Clock value in ms when the response was received.

Methods

Methods that depend on the clock take an optional now (ms or Date).

Method RFC 9111 Returns
storable() 3, 3.5 boolean
satisfies(request) 4, 4.1 boolean: URL, method and Vary match; Vary: * never matches
CachePolicy.select(policies, request) 4, 4.1 the most recent satisfying policy, or undefined
freshness(now?) 4.2 { fresh, heuristic, lifetime, age, ttl, staleFor } (seconds)
age(now?) 4.2.3 whole seconds
canServeStale(allowance, now?) 4.2.4, RFC 5861 boolean; allowance is { maxStale?: number | true, staleIfError?: boolean | number, staleWhileRevalidate?: boolean, disconnected?: boolean }
evaluate(request, now?) 4, 5.2.1 { action, reason, status?, headers?, revalidation? }
responseHeaders(now?) 3.2, 4, 4.2.3 headers to serve; Age replaced, Date untouched
notModifiedHeaders(now?) RFC 9110 15.4.5 headers for a 304
precondition(request) 4.3.2 'not-modified' or 'proceed'
revalidationRequest(request?) 4.3.1 { method, url, headers, conditional }
update(response, options?) 4.3.4, 3.2 { policy, updated, reason } for a 304
updateFromHead(response, options?) 4.3.5 { policy, updated, reason } for a HEAD 200
toObject() / CachePolicy.fromObject() JSON snapshot

Getters: status, method, url, shared, etag, lastModified, date, cacheControl. hasValidators() reports whether revalidation can be conditional.

Functions

Function Description
invalidates(request, response) URLs to invalidate after an unsafe request (RFC 9111 4.4): the target, plus same-origin Location and Content-Location. Empty for safe methods and error statuses.
parseCacheControl(value) Parses RFC 9111 5.2. Returns an object with has(name), seconds(name) (number, null if invalid, undefined if absent) and fields(name) for the no-cache and private field lists.
parseHttpDate(value, now?) Strict HTTP-date: IMF-fixdate, RFC 850 and asctime. Returns ms or undefined.
formatHttpDate(ms) IMF-fixdate.
parseVary(value) { star, names, valid }.
normalizeUrl(url) Fragment removed, host and scheme lower-cased, default port removed.

How it works

  • Storing follows RFC 9111 section 3 in order. Fields that must not be stored (Connection and the fields it lists, proxy fields, and the fields named by a qualified private in a shared cache or a qualified no-cache) are removed when the policy is created.
  • Freshness lifetime takes the first of s-maxage (shared), max-age, Expires minus Date, then a heuristic. An invalid max-age or Expires gives a lifetime of zero and suppresses the heuristic. Age is the larger of the apparent age and the Age header plus response delay, plus resident time.
  • Directives are split on commas outside quoted strings, compared case-insensitively, and accept token or quoted arguments. A repeated delta-seconds directive with conflicting values, or any invalid argument, makes that directive invalid.
  • Dates are matched against the three grammars of RFC 9110 5.6.7 and nothing else. Day and month names and GMT match case-insensitively (RFC 9111 4.2). Two-digit RFC 850 years resolve to the latest year not more than 50 years ahead. The day name is not checked against the date.
  • Vary matching compares the request headers as they were stored against the new request after removing insignificant whitespace, combining field lines, folding case for Accept-Language and Accept-Encoding, and ignoring the order of their members. A * member, or a member that is not a field name, never matches.
  • Stale service is refused first, by no-cache, must-revalidate, and in a shared cache proxy-revalidate and s-maxage. Only then do max-stale, stale-while-revalidate, stale-if-error and disconnected count.
  • update() drops the stored Age and Date before merging, so the freshened response is aged from the 304. It never takes Content-Length, Content-Range, Content-Encoding, Vary or hop-by-hop fields from a 304.

Limitations

  • Only GET and HEAD responses are stored. POST caching is not implemented.
  • 206 responses are not stored and partial content is not combined. A caller can slice a complete stored body itself.
  • Bodies, storage, eviction, request collapsing and background scheduling belong to the caller.
  • Vary selection among several stored variants is by Date only. Ranking by q values is not implemented.
  • A 304 that carries no validator updates the stored response. RFC 9111 4.3.4 does not say to do so.
  • When a request carries both max-age and max-stale, a response older than the max-age is revalidated.
  • The Warning header is not generated.
  • Request Cache-Control is read by evaluate(). no-transform is not interpreted.
  • ignoreCargoCult is a deliberate deviation from the RFC and is off by default.
  • Cache-tests results that remain (three runs of this package gave identical results):
    • required headers-store-Content-Length is not run: the suite's origin sends a Content-Length that does not match the body, the harness's Node HTTP client rejects it, and the policy never sees the message.
    • optional method-POST: POST responses are not stored.
    • optional vary-normalise-lang-select: needs q-value ranking among stored variants.
    • optional conditional-lm-fresh-no-lm: the suite expects a 304 for an If-Modified-Since older than Date on a response without Last-Modified. RFC 9111 4.3.2 says to compare with Date, which gives a 200.
    • optional partial-store-partial-reuse-partial, -byterange, -absent, -suffix and partial-store-partial-complete: store and combine 206 responses.

Compatibility

Run: Node 24.20 (type-stripped tests and the built dist/). The CI workflow also builds and runs test/smoke.mjs on Node 20.6, 22 and 24; those runs have not happened yet. The package is ESM only and uses no Node APIs.

Development

npm install
npx tsc --noEmit
npm test                    # unit, property and fuzz tests
npm run build && npm run test:dist
FUZZ_RUNS=1000000 FUZZ_SEED=7 npm test   # longer property and fuzz run

npm run bench:fetch         # downloads cache-tests at a pinned commit into .cache/ (BSD-3-Clause, never committed)
npm run bench               # runs the suite through bench/proxy.ts
node bench/matrix.mjs       # scores every result file, including the suite's public results

The comparison proxies need npm install inside bench/compare first.

License

MIT. See LICENSE.

About

RFC 9111 HTTP caching policy engine with RFC 5861 stale extensions. Decides storability, freshness, Vary matching, revalidation and invalidation for stored request/response pairs. No network code, no dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages