http-cache-semantics
Advanced tools
Comparing version 2.0.0 to 3.0.0
102
index.js
@@ -8,3 +8,2 @@ 'use strict'; | ||
function parseCacheControl(header) { | ||
@@ -16,12 +15,8 @@ const cc = {}; | ||
// the directive's value is considered invalid. Caches are encouraged to consider responses that have invalid freshness information to be stale | ||
const parts = header.split(/\s*,\s*/); // TODO: lame parsing | ||
const parts = header.trim().split(/\s*,\s*/); // TODO: lame parsing | ||
for(const part of parts) { | ||
const [k,v] = part.split(/\s*=\s*/); | ||
const [k,v] = part.split(/\s*=\s*/, 2); | ||
cc[k] = (v === undefined) ? true : v.replace(/^"|"$/g, ''); // TODO: lame unquoting | ||
} | ||
// The s-maxage directive also implies the semantics of the proxy-revalidate response directive. | ||
if ('s-maxage' in cc) { | ||
cc['proxy-revalidate'] = true; | ||
} | ||
return cc; | ||
@@ -40,5 +35,8 @@ } | ||
this._isShared = shared !== false; | ||
this._res = res; | ||
this._status = 'status' in res ? res.status : 200; | ||
this._resHeaders = res.headers; | ||
this._rescc = parseCacheControl(res.headers['cache-control']); | ||
this._req = req; | ||
this._method = 'method' in req ? req.method : 'GET'; | ||
this._url = req.url; | ||
this._reqHeaders = req.headers; | ||
this._reqcc = parseCacheControl(req.headers['cache-control']); | ||
@@ -59,4 +57,2 @@ | ||
storable() { | ||
const status = (this._res.status === undefined) ? 200 : this._res.status; | ||
// The "no-store" request directive indicates that a cache MUST NOT store any part of either this request or any response to it. | ||
@@ -66,5 +62,5 @@ return !this._reqcc['no-store'] && | ||
// The request method is understood by the cache and defined as being cacheable, and | ||
(!this._req.method || 'GET' === this._req.method || 'HEAD' === this._req.method || ('POST' === this._req.method && this._hasExplicitExpiration())) && | ||
('GET' === this._method || 'HEAD' === this._method || ('POST' === this._method && this._hasExplicitExpiration())) && | ||
// the response status code is understood by the cache, and | ||
understoodStatuses.includes(status) && | ||
understoodStatuses.includes(this._status) && | ||
// the "no-store" cache directive does not appear in request or response header fields, and | ||
@@ -75,7 +71,7 @@ !this._rescc['no-store'] && | ||
// the Authorization header field does not appear in the request, if the cache is shared, | ||
(!this._isShared || !this._req.headers['authorization'] || this._allowsStoringAuthenticated()) && | ||
(!this._isShared || !this._reqHeaders.authorization || this._allowsStoringAuthenticated()) && | ||
// the response either: | ||
( | ||
// contains an Expires header field, or | ||
this._res.headers.expires || | ||
this._resHeaders.expires || | ||
// contains a max-age response directive, or | ||
@@ -86,3 +82,3 @@ // contains a s-maxage response directive and the cache is shared, or | ||
// has a status code that is defined as cacheable by default | ||
statusCodeCacheableByDefault.includes(status) | ||
statusCodeCacheableByDefault.includes(this._status) | ||
); | ||
@@ -95,26 +91,52 @@ }, | ||
this._rescc['max-age'] || | ||
this._res.headers.expires; | ||
this._resHeaders.expires; | ||
}, | ||
satisfiesWithoutRevalidation(req) { | ||
if (!req || !req.headers) { | ||
throw Error("Request headers missing"); | ||
} | ||
// When presented with a request, a cache MUST NOT reuse a stored response, unless: | ||
// the presented request does not contain the no-cache pragma (Section 5.4), nor the no-cache cache directive, | ||
// unless the stored response is successfully validated (Section 4.3), and | ||
const requestCC = parseCacheControl(req.headers['cache-control']); | ||
if (requestCC['no-cache'] || /no-cache/.test(req.headers.pragma)) { | ||
return false; | ||
} | ||
// The presented effective request URI and that of the stored response match, and | ||
return (!this._url || this._url === req.url) && | ||
(this._reqHeaders.host === req.headers.host) && | ||
// the request method associated with the stored response allows it to be used for the presented request, and | ||
(!req.method || this._method === req.method) && | ||
// selecting header fields nominated by the stored response (if any) match those presented, and | ||
this._varyMatches(req) && | ||
// the stored response is either: | ||
// fresh, or allowed to be served stale | ||
!this.stale() // TODO: allow stale | ||
}, | ||
_allowsStoringAuthenticated() { | ||
// following Cache-Control response directives (Section 5.2.2) have such an effect: must-revalidate, public, and s-maxage. | ||
return this._rescc['must-revalidate'] || this._rescc['public'] || this._rescc['s-maxage']; | ||
return this._rescc['must-revalidate'] || this._rescc.public || this._rescc['s-maxage']; | ||
}, | ||
_varyKeyForRequest(req) { | ||
if (!this._res.headers.vary) return ''; | ||
_varyMatches(req) { | ||
if (!this._resHeaders.vary) { | ||
return true; | ||
} | ||
let key = ''; | ||
const fields = this._res.headers.vary.toLowerCase().split(/\s*,\s*/); | ||
fields.sort(); | ||
// A Vary header field-value of "*" always fails to match | ||
if (this._reqHeaders.vary === '*') { | ||
return false; | ||
} | ||
const fields = this._resHeaders.vary.trim().toLowerCase().split(/\s*,\s*/); | ||
for(const name of fields) { | ||
key += `${name}:${req.headers[name] || '÷'}\n`; | ||
if (req.headers[name] !== this._reqHeaders[name]) return false; | ||
} | ||
return key; | ||
return true; | ||
}, | ||
cacheKey() { | ||
return `${this._req.method || 'GET'} ${this._req.url || ''} ${this._varyKeyForRequest(this._req)}`; | ||
}, | ||
/** | ||
@@ -125,3 +147,3 @@ * Value of the Date response header or current time if Date was demed invalid | ||
date() { | ||
const dateValue = Date.parse(this._res.headers.date) | ||
const dateValue = Date.parse(this._resHeaders.date) | ||
const maxClockDrift = 8*3600*1000; | ||
@@ -140,4 +162,4 @@ if (Number.isNaN(dateValue) || dateValue < this._responseTime-maxClockDrift || dateValue > this._responseTime+maxClockDrift) { | ||
let age = Math.max(0, (this._responseTime - this.date())/1000); | ||
if (this._res.headers.age) { | ||
let ageValue = parseInt(this._res.headers.age); | ||
if (this._resHeaders.age) { | ||
let ageValue = parseInt(this._resHeaders.age); | ||
if (isFinite(ageValue)) { | ||
@@ -159,7 +181,7 @@ if (ageValue > age) age = ageValue; | ||
// so this implementation requires explicit opt-in via public header | ||
if (this._isShared && (this._res.headers['set-cookie'] && !this._rescc['public'])) { | ||
if (this._isShared && (this._resHeaders['set-cookie'] && !this._rescc.public)) { | ||
return 0; | ||
} | ||
if (this._res.headers.vary === '*') { | ||
if (this._resHeaders.vary === '*') { | ||
return 0; | ||
@@ -169,2 +191,5 @@ } | ||
if (this._isShared) { | ||
if (this._rescc['proxy-revalidate']) { | ||
return 0; | ||
} | ||
// if a response includes the s-maxage directive, a shared cache recipient MUST ignore the Expires field. | ||
@@ -182,4 +207,4 @@ if (this._rescc['s-maxage']) { | ||
const dateValue = this.date(); | ||
if (this._res.headers['expires']) { | ||
const expires = Date.parse(this._res.headers['expires']); | ||
if (this._resHeaders.expires) { | ||
const expires = Date.parse(this._resHeaders.expires); | ||
// A cache recipient MUST interpret invalid date formats, especially the value "0", as representing a time in the past (i.e., "already expired"). | ||
@@ -192,4 +217,4 @@ if (Number.isNaN(expires) || expires < dateValue) { | ||
if (this._res.headers['last-modified']) { | ||
const lastModified = Date.parse(this._res.headers['last-modified']); | ||
if (this._resHeaders['last-modified']) { | ||
const lastModified = Date.parse(this._resHeaders['last-modified']); | ||
if (isFinite(lastModified) && dateValue > lastModified) { | ||
@@ -208,2 +233,1 @@ return (dateValue - lastModified) * 0.00001; // In absence of other information cache for 1% of item's age | ||
module.exports = CachePolicy; | ||
{ | ||
"name": "http-cache-semantics", | ||
"version": "2.0.0", | ||
"version": "3.0.0", | ||
"description": "Parses Cache-Control headers and friends", | ||
@@ -5,0 +5,0 @@ "main": "index.js", |
@@ -22,5 +22,6 @@ # HTTP cache semantics | ||
const request = { | ||
url: '/', | ||
method: 'GET', | ||
headers: { | ||
'accept': '*/*', | ||
accept: '*/*', | ||
}, | ||
@@ -43,2 +44,12 @@ }; | ||
### `satisfiesWithoutRevalidation(request)` | ||
If it returns `true`, then the given `request` matches the response this cache policy has been created with, and the existing response can be used without contacting the server. | ||
If it returns `false`, then the response may not be matching at all (e.g. it's different URL or method), or may require to be refreshed first. | ||
### `storable()` | ||
Returns `true` if the response can be stored in a cache. If it's `false` then you MUST NOT store either request or the response. | ||
### `stale()` | ||
@@ -50,8 +61,2 @@ | ||
### `cacheKey()` | ||
Returns a string that is a combination of method, URL, and headers selected with `Vary`. | ||
Note that `Vary: *` never matches any request, so matching of cache keys alone is not sufficient to satisfy a request. | ||
## Implemented | ||
@@ -58,0 +63,0 @@ |
@@ -34,2 +34,19 @@ 'use strict'; | ||
it('Proxy cacheable auth is OK', function() { | ||
const cache = new CachePolicy({method:'GET',headers:{'authorization': 'test'}}, {headers:{'cache-control':'max-age=0,s-maxage=12'}}); | ||
assert(!cache.stale()); | ||
assert(cache.storable()); | ||
}); | ||
it('Private auth is OK', function() { | ||
const cache = new CachePolicy({method:'GET',headers:{'authorization': 'test'}}, cacheableResponse, {shared:false}); | ||
assert(!cache.stale()); | ||
assert(cache.storable()); | ||
}); | ||
it('Revalidated auth is OK', function() { | ||
const cache = new CachePolicy({headers:{'authorization': 'test'}}, {headers:{'cache-control':'max-age=88,must-revalidate'}}); | ||
assert(cache.storable()); | ||
}); | ||
it('Auth prevents caching by default', function() { | ||
@@ -36,0 +53,0 @@ const cache = new CachePolicy({method:'GET',headers:{'authorization': 'test'}}, cacheableResponse); |
@@ -20,2 +20,14 @@ 'use strict'; | ||
it('weird syntax', function() { | ||
const cache = new CachePolicy(req, {headers:{'cache-control': ',,,,max-age = 456 ,'}}); | ||
assert(!cache.stale()); | ||
assert.equal(cache.maxAge(), 456); | ||
}); | ||
it('quoted syntax', function() { | ||
const cache = new CachePolicy(req, {headers:{'cache-control': ' max-age = "678" '}}); | ||
assert(!cache.stale()); | ||
assert.equal(cache.maxAge(), 678); | ||
}); | ||
it('cache with expires', function() { | ||
@@ -40,2 +52,49 @@ const cache = new CachePolicy(req, {headers:{ | ||
it('Ages', function() { | ||
let now = 1000; | ||
class TimeTravellingPolicy extends CachePolicy { | ||
now() { | ||
return now; | ||
} | ||
} | ||
const cache = new TimeTravellingPolicy(req, {headers:{ | ||
'cache-control':'max-age=100', | ||
'age': '50', | ||
}}); | ||
assert(cache.storable()); | ||
assert(!cache.stale()); | ||
now += 48*1000; | ||
assert(!cache.stale()); | ||
now += 5*1000; | ||
assert(cache.stale()); | ||
}); | ||
it('Age can make stale', function() { | ||
const cache = new CachePolicy(req, {headers:{ | ||
'cache-control':'max-age=100', | ||
'age': '101', | ||
}}); | ||
assert(cache.stale()); | ||
assert(cache.storable()); | ||
}); | ||
it('Age not always stale', function() { | ||
const cache = new CachePolicy(req, {headers:{ | ||
'cache-control':'max-age=20', | ||
'age': '15', | ||
}}); | ||
assert(!cache.stale()); | ||
assert(cache.storable()); | ||
}); | ||
it('Bogus age ignored', function() { | ||
const cache = new CachePolicy(req, {headers:{ | ||
'cache-control':'max-age=20', | ||
'age': 'golden', | ||
}}); | ||
assert(!cache.stale()); | ||
assert(cache.storable()); | ||
}); | ||
it('cache old files', function() { | ||
@@ -42,0 +101,0 @@ const cache = new CachePolicy(req, {headers:{ |
@@ -8,69 +8,69 @@ 'use strict'; | ||
it('Basic', function() { | ||
const cache1 = new CachePolicy({method:'GET',headers:{'weather': 'nice'}}, {headers:{'vary':'weather'}}); | ||
const cache2 = new CachePolicy({method:'GET',headers:{'weather': 'bad'}}, {headers:{'vary':'WEATHER'}}); | ||
const policy = new CachePolicy({headers:{'weather': 'nice'}}, {headers:{'cache-control':'max-age=5','vary':'weather'}}); | ||
assert.equal(cache1.cacheKey(), cache1.cacheKey()); | ||
assert.equal(cache2.cacheKey(), cache2.cacheKey()); | ||
assert.notEqual(cache1.cacheKey(), cache2.cacheKey()); | ||
assert(policy.satisfiesWithoutRevalidation({headers:{'weather': 'nice'}})); | ||
assert(!policy.satisfiesWithoutRevalidation({headers:{'weather': 'bad'}})); | ||
}); | ||
it("* doesn't match other", function() { | ||
const cache1 = new CachePolicy({method:'GET',headers:{'weather': 'ok'}}, {headers:{'vary':'*'}}); | ||
const cache2 = new CachePolicy({method:'GET',headers:{'weather': 'ok'}}, {headers:{'vary':'weather'}}); | ||
it("* doesn't match", function() { | ||
const policy = new CachePolicy({headers:{'weather': 'ok'}}, {headers:{'cache-control':'max-age=5','vary':'*'}}); | ||
assert.equal(cache2.cacheKey(), cache2.cacheKey()); | ||
assert.notEqual(cache1.cacheKey(), cache2.cacheKey()); | ||
assert(!policy.satisfiesWithoutRevalidation({headers:{'weather': 'ok'}})); | ||
}); | ||
it("* is stale", function() { | ||
const cache1 = new CachePolicy({method:'GET',headers:{'weather': 'ok'}}, {headers:{'cache-control':'public,max-age=99', 'vary':'*'}}); | ||
const cache2 = new CachePolicy({method:'GET',headers:{'weather': 'ok'}}, {headers:{'cache-control':'public,max-age=99', 'vary':'weather'}}); | ||
const policy1 = new CachePolicy({headers:{'weather': 'ok'}}, {headers:{'cache-control':'public,max-age=99', 'vary':'*'}}); | ||
const policy2 = new CachePolicy({headers:{'weather': 'ok'}}, {headers:{'cache-control':'public,max-age=99', 'vary':'weather'}}); | ||
assert.notEqual(cache1.cacheKey(), cache2.cacheKey()); | ||
assert(cache1.stale()); | ||
assert(!cache2.stale()); | ||
assert(policy1.stale()); | ||
assert(!policy2.stale()); | ||
}); | ||
it('Values are case-sensitive', function() { | ||
const cache1 = new CachePolicy({method:'GET',headers:{'weather': 'BAD'}}, {headers:{'vary':'weather'}}); | ||
const cache2 = new CachePolicy({method:'GET',headers:{'weather': 'bad'}}, {headers:{'vary':'weather'}}); | ||
const policy = new CachePolicy({headers:{'weather': 'BAD'}}, {headers:{'cache-control':'max-age=5','vary':'Weather'}}); | ||
assert.notEqual(cache1.cacheKey(), cache2.cacheKey()); | ||
assert(policy.satisfiesWithoutRevalidation({headers:{'weather': 'BAD'}})); | ||
assert(!policy.satisfiesWithoutRevalidation({headers:{'weather': 'bad'}})); | ||
}); | ||
it('Irrelevant headers ignored', function() { | ||
const cache1 = new CachePolicy({method:'GET',headers:{'weather': 'nice'}}, {headers:{'vary':'moon-phase'}}); | ||
const cache2 = new CachePolicy({method:'GET',headers:{'weather': 'bad'}}, {headers:{'vary':'moon-phase'}}); | ||
const policy = new CachePolicy({headers:{'weather': 'nice'}}, {headers:{'cache-control':'max-age=5','vary':'moon-phase'}}); | ||
assert.equal(cache1.cacheKey(), cache1.cacheKey()); | ||
assert.equal(cache1.cacheKey(), cache2.cacheKey()); | ||
assert(policy.satisfiesWithoutRevalidation({headers:{'weather': 'bad'}})); | ||
assert(policy.satisfiesWithoutRevalidation({headers:{'sun': 'shining'}})); | ||
assert(!policy.satisfiesWithoutRevalidation({headers:{'moon-phase': 'full'}})); | ||
}); | ||
it('Absence is meaningful', function() { | ||
const cache1 = new CachePolicy({method:'GET',headers:{'weather': 'nice'}}, {headers:{'vary':'moon-phase'}}); | ||
const cache2 = new CachePolicy({method:'GET',headers:{'weather': 'bad'}}, {headers:{'vary':'sunshine'}}); | ||
const policy = new CachePolicy({headers:{'weather': 'nice'}}, {headers:{'cache-control':'max-age=5','vary':'moon-phase, weather'}}); | ||
assert.equal(cache2.cacheKey(), cache2.cacheKey()); | ||
assert.notEqual(cache1.cacheKey(), cache2.cacheKey()); | ||
assert(policy.satisfiesWithoutRevalidation({headers:{'weather': 'nice'}})); | ||
assert(!policy.satisfiesWithoutRevalidation({headers:{'weather': 'nice', 'moon-phase': ''}})); | ||
assert(!policy.satisfiesWithoutRevalidation({headers:{}})); | ||
}); | ||
it('All values must match', function() { | ||
const cache1 = new CachePolicy({method:'GET',headers:{'sun': 'shining', 'weather': 'nice'}}, {headers:{'vary':'weather, sun'}}); | ||
const cache2 = new CachePolicy({method:'GET',headers:{'sun': 'shining', 'weather': 'bad'}}, {headers:{'vary':'weather, sun'}}); | ||
assert.notEqual(cache1.cacheKey(), cache2.cacheKey()); | ||
const policy = new CachePolicy({headers:{'sun': 'shining', 'weather': 'nice'}}, {headers:{'cache-control':'max-age=5','vary':'weather, sun'}}); | ||
assert(policy.satisfiesWithoutRevalidation({headers:{'sun': 'shining', 'weather': 'nice'}})); | ||
assert(!policy.satisfiesWithoutRevalidation({headers:{'sun': 'shining', 'weather': 'bad'}})); | ||
}); | ||
it('Whitespace is OK', function() { | ||
const policy = new CachePolicy({headers:{'sun': 'shining', 'weather': 'nice'}}, {headers:{'cache-control':'max-age=5','vary':' weather , sun '}}); | ||
assert(policy.satisfiesWithoutRevalidation({headers:{'sun': 'shining', 'weather': 'nice'}})); | ||
assert(!policy.satisfiesWithoutRevalidation({headers:{'weather': 'nice'}})); | ||
assert(!policy.satisfiesWithoutRevalidation({headers:{'sun': 'shining'}})); | ||
}); | ||
it('Order is irrelevant', function() { | ||
const cache1 = new CachePolicy({method:'GET',headers:{'weather': 'nice'}}, {headers:{'vary':'moon-phase, SUNSHINE'}}); | ||
const cache2 = new CachePolicy({method:'GET',headers:{'weather': 'bad'}}, {headers:{'vary':'sunshine, moon-phase'}}); | ||
assert.equal(cache1.cacheKey(), cache2.cacheKey()); | ||
const policy1 = new CachePolicy({headers:{'sun': 'shining', 'weather': 'nice'}}, {headers:{'cache-control':'max-age=5','vary':'weather, sun'}}); | ||
const policy2 = new CachePolicy({headers:{'sun': 'shining', 'weather': 'nice'}}, {headers:{'cache-control':'max-age=5','vary':'sun, weather'}}); | ||
const cache3 = new CachePolicy({method:'GET',headers:{'weather': 'nice'}}, {headers:{'vary':'moon-phase, weather'}}); | ||
const cache4 = new CachePolicy({method:'GET',headers:{'weather': 'nice'}}, {headers:{'vary':'weather, moon-phase'}}); | ||
assert.equal(cache3.cacheKey(), cache4.cacheKey()); | ||
assert.notEqual(cache1.cacheKey(), cache3.cacheKey()); | ||
assert.notEqual(cache2.cacheKey(), cache4.cacheKey()); | ||
assert(policy1.satisfiesWithoutRevalidation({headers:{'weather': 'nice', 'sun': 'shining'}})); | ||
assert(policy1.satisfiesWithoutRevalidation({headers:{'sun': 'shining', 'weather': 'nice'}})); | ||
assert(policy2.satisfiesWithoutRevalidation({headers:{'weather': 'nice', 'sun': 'shining'}})); | ||
assert(policy2.satisfiesWithoutRevalidation({headers:{'sun': 'shining', 'weather': 'nice'}})); | ||
}); | ||
}); |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
29006
8
559
71
0