commit c07f89dfffbe3f452baa12c30ba84ad4bac7a176 Author: Kornel Lesiński Date: Sat May 21 14:20:47 2016 +0100 Hello diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..98886f5 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# HTTP cache semantics + +`CachePolicy` object that computes properties of a HTTP response, such as whether it's fresh or stale, and how long it can be cached for. Based on RFC 7234. + +## Usage + +```js +const cache = new CachePolicy(request, response, options); + +// Age counts from the time response has been created +const secondsFresh = cache.maxAge(); +const secondsOld = cache.age(); + +// Current state +const currentState = cache.isFresh(); +``` + +Cacheability of response depends on how it was requested, so both request and response are required. Both are objects with `headers` property that is an object with lowercased header names as keys, e.g. + +```js +const request = { + method: 'GET', + headers: { + 'accept': '*/*', + }, +}; + +const response = { + status: 200, + headers: { + 'cache-control': 'public, max-age=7234', + }, +}; + +const options = { + shared: true, +}; +``` + +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). + +## Implemented + +* `Expires` with check for bad clocks +* `Cache-Control` response header +* `Pragma` response header +* `Age` response header + +## Unimplemented + +* Request properties are not evaluated +* `Vary` support is as lame and incomplete as in web browsers +* No support for revalidation and stale responses diff --git a/index.js b/index.js new file mode 100644 index 0000000..ed03b9d --- /dev/null +++ b/index.js @@ -0,0 +1,127 @@ +'use strict'; + +function parseCacheControl(header) { + const cc = {}; + if (!header) return cc; + + // TODO: When there is more than one value present for a given directive (e.g., two Expires header fields, multiple Cache-Control: max-age directives), + // 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 + for(const part of parts) { + const [k,v] = part.split(/\s*=\s*/); + 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; +} + +function CachePolicy(req, res, {shared} = {}) { + if (!res || !res.headers) { + throw Error("headers missing"); + } + + this._responseTime = this.now(); + this._isShared = shared !== false; + this._res = res; + this._cc = parseCacheControl(res.headers['cache-control']); + + // When the Cache-Control header field is not present in a request, caches MUST consider the no-cache request pragma-directive + // as having the same effect as if "Cache-Control: no-cache" were present (see Section 5.2.1). + if (!res.headers['cache-control'] && /no-cache/.test(res.headers.pragma)) { + this._cc['no-cache'] = true; + } +} + +CachePolicy.prototype = { + now() { + return Date.now(); + }, + + /** + * Value of the Date response header or current time if Date was demed invalid + * @return timestamp + */ + date() { + const dateValue = Date.parse(this._res.headers.date) + const maxClockDrift = 8*3600*1000; + if (Number.isNaN(dateValue) || dateValue < this._responseTime-maxClockDrift || dateValue > this._responseTime+maxClockDrift) { + return this._responseTime; + } + return dateValue; + }, + + /** + * Value of the Age header, in seconds, updated for the current time + * @return Number + */ + age() { + let age = Math.max(0, (this._responseTime - this.date())/1000); + if (this._res.headers.age) { + let ageValue = parseInt(this._res.headers.age); + if (isFinite(ageValue)) { + if (ageValue > age) age = ageValue; + } + } + + const residentTime = (this.now() - this._responseTime)/1000; + return age + residentTime; + }, + + maxAge() { + if (this._cc['no-cache'] || this._cc['no-store']) { + return 0; + } + + // Shared responses with cookies are cacheable according to the RFC, but IMHO it'd be unwise to do so by default + // so this implementation requires explicit opt-in via public header + if (this._isShared && (this._cc['private'] || (this._res.headers['set-cookie'] && !this._cc['public']))) { + return 0; + } + + // TODO: vary is not supported yet + if (this._res.headers['vary']) { + return 0; + } + + if (this._isShared) { + // if a response includes the s-maxage directive, a shared cache recipient MUST ignore the Expires field. + if (this._cc['s-maxage']) { + return parseInt(this._cc['s-maxage'], 10); + } + } + + // If a response includes a Cache-Control field with the max-age directive, a recipient MUST ignore the Expires field. + if (this._cc['max-age']) { + return parseInt(this._cc['max-age'], 10); + } + + const dateValue = this.date(); + if (this._res.headers['expires']) { + const expires = Date.parse(this._res.headers['expires']); + // A cache recipient MUST interpret invalid date formats, especially the value "0", as representing a time in the past (i.e., "already expired"). + if (Number.isNaN(expires) || expires < dateValue) { + return 0; + } + return (expires - dateValue)/1000; + } + + if (this._res.headers['last-modified']) { + const lastModified = Date.parse(this._res.headers['last-modified']); + if (isFinite(lastModified) && dateValue > lastModified) { + return (dateValue - lastModified) * 0.00001; // In absence of other information cache for 1% of item's age + } + } + return 0; + }, + + isFresh() { + return this.maxAge() > this.age(); + }, +}; + +module.exports = CachePolicy; + diff --git a/package.json b/package.json new file mode 100644 index 0000000..fd411a5 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "http-cache-semantics", + "version": "1.0.0", + "description": "Parses Cache-Control headers and friends", + "main": "index.js", + "repository": "https://github.com/pornel/http-cache-semantics.git", + "scripts": { + "test": "mocha" + }, + "author": "Kornel Lesiński (https://kornel.ski/)", + "license": "BSD-2-Clause", + "devDependencies": { + "mocha": "^2.4.5" + } +} diff --git a/test/cachetest.js b/test/cachetest.js new file mode 100644 index 0000000..b487a05 --- /dev/null +++ b/test/cachetest.js @@ -0,0 +1,138 @@ +'use strict'; + +const assert = require('assert'); +const CachePolicy = require('..'); + +describe('Cache', function() { + it('simple miss', function() { + const cache = new CachePolicy({}, {headers:{}}); + assert(!cache.isFresh()); + }); + + it('simple hit', function() { + const cache = new CachePolicy({}, {headers:{'cache-control': 'public, max-age=999999'}}); + assert(cache.isFresh()); + assert.equal(cache.maxAge(), 999999); + }); + + it('cache with expires', function() { + const cache = new CachePolicy({}, {headers:{ + 'date': new Date().toGMTString(), + 'expires': new Date(Date.now() + 2000).toGMTString(), + }}); + assert(cache.isFresh()); + assert.equal(2, cache.maxAge()); + }); + + it('cache expires no date', function() { + const cache = new CachePolicy({}, {headers:{ + 'cache-control': 'public', + 'expires': new Date(Date.now()+3600*1000).toGMTString(), + }}); + assert(cache.isFresh()); + assert(cache.maxAge() > 3595); + assert(cache.maxAge() < 3605); + }); + + it('cache old files', function() { + const cache = new CachePolicy({}, {headers:{ + 'date': new Date().toGMTString(), + 'last-modified': 'Mon, 07 Mar 2016 11:52:56 GMT', + }}); + assert(cache.isFresh()); + assert(cache.maxAge() > 100); + }); + + it('pragma: no-cache', function() { + const cache = new CachePolicy({}, {headers:{ + 'pragma': 'no-cache', + 'last-modified': 'Mon, 07 Mar 2016 11:52:56 GMT', + }}); + assert(!cache.isFresh()); + }); + + it('no-store', function() { + const cache = new CachePolicy({}, {headers:{ + 'cache-control': 'no-store, public, max-age=1', + }}); + assert(!cache.isFresh()); + assert.equal(0, cache.maxAge()); + }); + + it('observe private cache', function() { + const privateHeader = { + 'cache-control': 'private, max-age=1234', + }; + const proxyCache = new CachePolicy({}, {headers:privateHeader}); + assert(!proxyCache.isFresh()); + assert.equal(0, proxyCache.maxAge()); + + const uaCache = new CachePolicy({}, {headers:privateHeader}, {shared:false}); + assert(uaCache.isFresh()); + assert.equal(1234, uaCache.maxAge()); + }); + + it('don\'t share cookies', function() { + const cookieHeader = { + 'set-cookie': 'foo=bar', + 'cache-control': 'max-age=99', + }; + const proxyCache = new CachePolicy({}, {headers:cookieHeader}, {shared:true}); + assert(!proxyCache.isFresh()); + assert.equal(0, proxyCache.maxAge()); + + const uaCache = new CachePolicy({}, {headers:cookieHeader}, {shared:false}); + assert(uaCache.isFresh()); + assert.equal(99, uaCache.maxAge()); + }); + + it('cache explicitly public cookie', function() { + const cookieHeader = { + 'set-cookie': 'foo=bar', + 'cache-control': 'max-age=5, public', + }; + const proxyCache = new CachePolicy({}, {headers:cookieHeader}, {shared:true}); + assert(proxyCache.isFresh()); + assert.equal(5, proxyCache.maxAge()); + }); + + it('miss max-age=0', function() { + const cache = new CachePolicy({}, {headers:{ + 'cache-control': 'public, max-age=0', + }}); + assert(!cache.isFresh()); + assert.equal(0, cache.maxAge()); + }); + + it('expired expires cached with max-age', function() { + const cache = new CachePolicy({}, {headers:{ + 'cache-control': 'public, max-age=9999', + 'expires': 'Sat, 07 May 2016 15:35:18 GMT', + }}); + assert(cache.isFresh()); + assert.equal(9999, cache.maxAge()); + }); + + it('expired expires cached with s-maxage', function() { + const sMaxAgeHeaders = { + 'cache-control': 'public, s-maxage=9999', + 'expires': 'Sat, 07 May 2016 15:35:18 GMT', + }; + const proxyCache = new CachePolicy({}, {headers:sMaxAgeHeaders}); + assert(proxyCache.isFresh()); + assert.equal(9999, proxyCache.maxAge()); + + const uaCache = new CachePolicy({}, {headers:sMaxAgeHeaders}, {shared:false}); + assert(!uaCache.isFresh()); + assert.equal(0, uaCache.maxAge()); + }); + + it('max-age wins over future expires', function() { + const cache = new CachePolicy({}, {headers:{ + 'cache-control': 'public, max-age=333', + 'expires': new Date(Date.now()+3600*1000).toGMTString(), + }}); + assert(cache.isFresh()); + assert.equal(333, cache.maxAge()); + }); +});