diff --git a/README.md b/README.md index e0ae7ae..c8f5651 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ Cacheability of response depends on how it was requested, so both request and re ```js const request = { + url: '/', method: 'GET', headers: { - 'accept': '*/*', + accept: '*/*', }, }; @@ -39,6 +40,12 @@ const options = { If `options.shared` is true (default), then response is evaluated from perspective of a shared cache (i.e. `private` is not cacheable and `s-maxage` is respected). If `options.shared` is false, then response is evaluated from perspective of a single-user cache (i.e. `private` is cacheable and `s-maxage` is ignored). +### `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. diff --git a/index.js b/index.js index c6c0830..6eb1c6c 100644 --- a/index.js +++ b/index.js @@ -88,21 +88,50 @@ CachePolicy.prototype = { this._res.headers.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._req.url || this._req.url === req.url) && + // the request method associated with the stored response allows it to be used for the presented request, and + (!this._req.method || this._req.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']; }, - _varyKeyForRequest(req) { - if (!this._res.headers.vary) return ''; + _varyMatches(req) { + if (!this._res.headers.vary) { + return true; + } + + // A Vary header field-value of "*" always fails to match + if (this._req.headers.vary === '*') { + return false; + } - let key = ''; const fields = this._res.headers.vary.toLowerCase().split(/\s*,\s*/); - fields.sort(); for(const name of fields) { - key += `${name}:${req.headers[name] || 'รท'}\n`; + if (req.headers[name] !== this._req.headers[name]) return false; } - return key; + return true; }, /** diff --git a/test/varytest.js b/test/varytest.js index 4396bc7..a23ac68 100644 --- a/test/varytest.js +++ b/test/varytest.js @@ -5,71 +5,63 @@ const CachePolicy = require('..'); describe('Vary', function() { 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('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 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()); + 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'}}); - 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'}})); }); });