You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
673 lines
23 KiB
673 lines
23 KiB
'use strict'; |
|
// rfc7231 6.1 |
|
const statusCodeCacheableByDefault = new Set([ |
|
200, |
|
203, |
|
204, |
|
206, |
|
300, |
|
301, |
|
404, |
|
405, |
|
410, |
|
414, |
|
501, |
|
]); |
|
|
|
// This implementation does not understand partial responses (206) |
|
const understoodStatuses = new Set([ |
|
200, |
|
203, |
|
204, |
|
300, |
|
301, |
|
302, |
|
303, |
|
307, |
|
308, |
|
404, |
|
405, |
|
410, |
|
414, |
|
501, |
|
]); |
|
|
|
const errorStatusCodes = new Set([ |
|
500, |
|
502, |
|
503, |
|
504, |
|
]); |
|
|
|
const hopByHopHeaders = { |
|
date: true, // included, because we add Age update Date |
|
connection: true, |
|
'keep-alive': true, |
|
'proxy-authenticate': true, |
|
'proxy-authorization': true, |
|
te: true, |
|
trailer: true, |
|
'transfer-encoding': true, |
|
upgrade: true, |
|
}; |
|
|
|
const excludedFromRevalidationUpdate = { |
|
// Since the old body is reused, it doesn't make sense to change properties of the body |
|
'content-length': true, |
|
'content-encoding': true, |
|
'transfer-encoding': true, |
|
'content-range': true, |
|
}; |
|
|
|
function toNumberOrZero(s) { |
|
const n = parseInt(s, 10); |
|
return isFinite(n) ? n : 0; |
|
} |
|
|
|
// RFC 5861 |
|
function isErrorResponse(response) { |
|
// consider undefined response as faulty |
|
if(!response) { |
|
return true |
|
} |
|
return errorStatusCodes.has(response.status); |
|
} |
|
|
|
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.trim().split(/\s*,\s*/); // TODO: lame parsing |
|
for (const part of parts) { |
|
const [k, v] = part.split(/\s*=\s*/, 2); |
|
cc[k] = v === undefined ? true : v.replace(/^"|"$/g, ''); // TODO: lame unquoting |
|
} |
|
|
|
return cc; |
|
} |
|
|
|
function formatCacheControl(cc) { |
|
let parts = []; |
|
for (const k in cc) { |
|
const v = cc[k]; |
|
parts.push(v === true ? k : k + '=' + v); |
|
} |
|
if (!parts.length) { |
|
return undefined; |
|
} |
|
return parts.join(', '); |
|
} |
|
|
|
module.exports = class CachePolicy { |
|
constructor( |
|
req, |
|
res, |
|
{ |
|
shared, |
|
cacheHeuristic, |
|
immutableMinTimeToLive, |
|
ignoreCargoCult, |
|
_fromObject, |
|
} = {} |
|
) { |
|
if (_fromObject) { |
|
this._fromObject(_fromObject); |
|
return; |
|
} |
|
|
|
if (!res || !res.headers) { |
|
throw Error('Response headers missing'); |
|
} |
|
this._assertRequestHasHeaders(req); |
|
|
|
this._responseTime = this.now(); |
|
this._isShared = shared !== false; |
|
this._cacheHeuristic = |
|
undefined !== cacheHeuristic ? cacheHeuristic : 0.1; // 10% matches IE |
|
this._immutableMinTtl = |
|
undefined !== immutableMinTimeToLive |
|
? immutableMinTimeToLive |
|
: 24 * 3600 * 1000; |
|
|
|
this._status = 'status' in res ? res.status : 200; |
|
this._resHeaders = res.headers; |
|
this._rescc = parseCacheControl(res.headers['cache-control']); |
|
this._method = 'method' in req ? req.method : 'GET'; |
|
this._url = req.url; |
|
this._host = req.headers.host; |
|
this._noAuthorization = !req.headers.authorization; |
|
this._reqHeaders = res.headers.vary ? req.headers : null; // Don't keep all request headers if they won't be used |
|
this._reqcc = parseCacheControl(req.headers['cache-control']); |
|
|
|
// Assume that if someone uses legacy, non-standard uncecessary options they don't understand caching, |
|
// so there's no point stricly adhering to the blindly copy&pasted directives. |
|
if ( |
|
ignoreCargoCult && |
|
'pre-check' in this._rescc && |
|
'post-check' in this._rescc |
|
) { |
|
delete this._rescc['pre-check']; |
|
delete this._rescc['post-check']; |
|
delete this._rescc['no-cache']; |
|
delete this._rescc['no-store']; |
|
delete this._rescc['must-revalidate']; |
|
this._resHeaders = Object.assign({}, this._resHeaders, { |
|
'cache-control': formatCacheControl(this._rescc), |
|
}); |
|
delete this._resHeaders.expires; |
|
delete this._resHeaders.pragma; |
|
} |
|
|
|
// 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'] == null && |
|
/no-cache/.test(res.headers.pragma) |
|
) { |
|
this._rescc['no-cache'] = true; |
|
} |
|
} |
|
|
|
now() { |
|
return Date.now(); |
|
} |
|
|
|
storable() { |
|
// The "no-store" request directive indicates that a cache MUST NOT store any part of either this request or any response to it. |
|
return !!( |
|
!this._reqcc['no-store'] && |
|
// A cache MUST NOT store a response to any request, unless: |
|
// The request method is understood by the cache and defined as being cacheable, and |
|
('GET' === this._method || |
|
'HEAD' === this._method || |
|
('POST' === this._method && this._hasExplicitExpiration())) && |
|
// the response status code is understood by the cache, and |
|
understoodStatuses.has(this._status) && |
|
// the "no-store" cache directive does not appear in request or response header fields, and |
|
!this._rescc['no-store'] && |
|
// the "private" response directive does not appear in the response, if the cache is shared, and |
|
(!this._isShared || !this._rescc.private) && |
|
// the Authorization header field does not appear in the request, if the cache is shared, |
|
(!this._isShared || |
|
this._noAuthorization || |
|
this._allowsStoringAuthenticated()) && |
|
// the response either: |
|
// contains an Expires header field, or |
|
(this._resHeaders.expires || |
|
// contains a max-age response directive, or |
|
// contains a s-maxage response directive and the cache is shared, or |
|
// contains a public response directive. |
|
this._rescc['max-age'] || |
|
(this._isShared && this._rescc['s-maxage']) || |
|
this._rescc.public || |
|
// has a status code that is defined as cacheable by default |
|
statusCodeCacheableByDefault.has(this._status)) |
|
); |
|
} |
|
|
|
_hasExplicitExpiration() { |
|
// 4.2.1 Calculating Freshness Lifetime |
|
return ( |
|
(this._isShared && this._rescc['s-maxage']) || |
|
this._rescc['max-age'] || |
|
this._resHeaders.expires |
|
); |
|
} |
|
|
|
_assertRequestHasHeaders(req) { |
|
if (!req || !req.headers) { |
|
throw Error('Request headers missing'); |
|
} |
|
} |
|
|
|
satisfiesWithoutRevalidation(req) { |
|
this._assertRequestHasHeaders(req); |
|
|
|
// 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; |
|
} |
|
|
|
if (requestCC['max-age'] && this.age() > requestCC['max-age']) { |
|
return false; |
|
} |
|
|
|
if ( |
|
requestCC['min-fresh'] && |
|
this.timeToLive() < 1000 * requestCC['min-fresh'] |
|
) { |
|
return false; |
|
} |
|
|
|
// the stored response is either: |
|
// fresh, or allowed to be served stale |
|
if (this.stale()) { |
|
const allowsStale = |
|
requestCC['max-stale'] && |
|
!this._rescc['must-revalidate'] && |
|
(true === requestCC['max-stale'] || |
|
requestCC['max-stale'] > this.age() - this.maxAge()); |
|
if (!allowsStale) { |
|
return false; |
|
} |
|
} |
|
|
|
return this._requestMatches(req, false); |
|
} |
|
|
|
_requestMatches(req, allowHeadMethod) { |
|
// The presented effective request URI and that of the stored response match, and |
|
return ( |
|
(!this._url || this._url === req.url) && |
|
this._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 || |
|
(allowHeadMethod && 'HEAD' === req.method)) && |
|
// selecting header fields nominated by the stored response (if any) match those presented, and |
|
this._varyMatches(req) |
|
); |
|
} |
|
|
|
_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'] |
|
); |
|
} |
|
|
|
_varyMatches(req) { |
|
if (!this._resHeaders.vary) { |
|
return true; |
|
} |
|
|
|
// A Vary header field-value of "*" always fails to match |
|
if (this._resHeaders.vary === '*') { |
|
return false; |
|
} |
|
|
|
const fields = this._resHeaders.vary |
|
.trim() |
|
.toLowerCase() |
|
.split(/\s*,\s*/); |
|
for (const name of fields) { |
|
if (req.headers[name] !== this._reqHeaders[name]) return false; |
|
} |
|
return true; |
|
} |
|
|
|
_copyWithoutHopByHopHeaders(inHeaders) { |
|
const headers = {}; |
|
for (const name in inHeaders) { |
|
if (hopByHopHeaders[name]) continue; |
|
headers[name] = inHeaders[name]; |
|
} |
|
// 9.1. Connection |
|
if (inHeaders.connection) { |
|
const tokens = inHeaders.connection.trim().split(/\s*,\s*/); |
|
for (const name of tokens) { |
|
delete headers[name]; |
|
} |
|
} |
|
if (headers.warning) { |
|
const warnings = headers.warning.split(/,/).filter(warning => { |
|
return !/^\s*1[0-9][0-9]/.test(warning); |
|
}); |
|
if (!warnings.length) { |
|
delete headers.warning; |
|
} else { |
|
headers.warning = warnings.join(',').trim(); |
|
} |
|
} |
|
return headers; |
|
} |
|
|
|
responseHeaders() { |
|
const headers = this._copyWithoutHopByHopHeaders(this._resHeaders); |
|
const age = this.age(); |
|
|
|
// A cache SHOULD generate 113 warning if it heuristically chose a freshness |
|
// lifetime greater than 24 hours and the response's age is greater than 24 hours. |
|
if ( |
|
age > 3600 * 24 && |
|
!this._hasExplicitExpiration() && |
|
this.maxAge() > 3600 * 24 |
|
) { |
|
headers.warning = |
|
(headers.warning ? `${headers.warning}, ` : '') + |
|
'113 - "rfc7234 5.5.4"'; |
|
} |
|
headers.age = `${Math.round(age)}`; |
|
headers.date = new Date(this.now()).toUTCString(); |
|
return headers; |
|
} |
|
|
|
/** |
|
* Value of the Date response header or current time if Date was invalid |
|
* @return timestamp |
|
*/ |
|
date() { |
|
const serverDate = Date.parse(this._resHeaders.date); |
|
if (isFinite(serverDate)) { |
|
return serverDate; |
|
} |
|
return this._responseTime; |
|
} |
|
|
|
/** |
|
* Value of the Age header, in seconds, updated for the current time. |
|
* May be fractional. |
|
* |
|
* @return Number |
|
*/ |
|
age() { |
|
let age = this._ageValue(); |
|
|
|
const residentTime = (this.now() - this._responseTime) / 1000; |
|
return age + residentTime; |
|
} |
|
|
|
_ageValue() { |
|
return toNumberOrZero(this._resHeaders.age); |
|
} |
|
|
|
/** |
|
* Value of applicable max-age (or heuristic equivalent) in seconds. This counts since response's `Date`. |
|
* |
|
* For an up-to-date value, see `timeToLive()`. |
|
* |
|
* @return Number |
|
*/ |
|
maxAge() { |
|
if (!this.storable() || this._rescc['no-cache']) { |
|
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._resHeaders['set-cookie'] && |
|
!this._rescc.public && |
|
!this._rescc.immutable) |
|
) { |
|
return 0; |
|
} |
|
|
|
if (this._resHeaders.vary === '*') { |
|
return 0; |
|
} |
|
|
|
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. |
|
if (this._rescc['s-maxage']) { |
|
return toNumberOrZero(this._rescc['s-maxage']); |
|
} |
|
} |
|
|
|
// If a response includes a Cache-Control field with the max-age directive, a recipient MUST ignore the Expires field. |
|
if (this._rescc['max-age']) { |
|
return toNumberOrZero(this._rescc['max-age']); |
|
} |
|
|
|
const defaultMinTtl = this._rescc.immutable ? this._immutableMinTtl : 0; |
|
|
|
const serverDate = this.date(); |
|
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"). |
|
if (Number.isNaN(expires) || expires < serverDate) { |
|
return 0; |
|
} |
|
return Math.max(defaultMinTtl, (expires - serverDate) / 1000); |
|
} |
|
|
|
if (this._resHeaders['last-modified']) { |
|
const lastModified = Date.parse(this._resHeaders['last-modified']); |
|
if (isFinite(lastModified) && serverDate > lastModified) { |
|
return Math.max( |
|
defaultMinTtl, |
|
((serverDate - lastModified) / 1000) * this._cacheHeuristic |
|
); |
|
} |
|
} |
|
|
|
return defaultMinTtl; |
|
} |
|
|
|
timeToLive() { |
|
const age = this.maxAge() - this.age(); |
|
const staleIfErrorAge = age + toNumberOrZero(this._rescc['stale-if-error']); |
|
const staleWhileRevalidateAge = age + toNumberOrZero(this._rescc['stale-while-revalidate']); |
|
return Math.max(0, age, staleIfErrorAge, staleWhileRevalidateAge) * 1000; |
|
} |
|
|
|
stale() { |
|
return this.maxAge() <= this.age(); |
|
} |
|
|
|
_useStaleIfError() { |
|
return this.maxAge() + toNumberOrZero(this._rescc['stale-if-error']) > this.age(); |
|
} |
|
|
|
useStaleWhileRevalidate() { |
|
return this.maxAge() + toNumberOrZero(this._rescc['stale-while-revalidate']) > this.age(); |
|
} |
|
|
|
static fromObject(obj) { |
|
return new this(undefined, undefined, { _fromObject: obj }); |
|
} |
|
|
|
_fromObject(obj) { |
|
if (this._responseTime) throw Error('Reinitialized'); |
|
if (!obj || obj.v !== 1) throw Error('Invalid serialization'); |
|
|
|
this._responseTime = obj.t; |
|
this._isShared = obj.sh; |
|
this._cacheHeuristic = obj.ch; |
|
this._immutableMinTtl = |
|
obj.imm !== undefined ? obj.imm : 24 * 3600 * 1000; |
|
this._status = obj.st; |
|
this._resHeaders = obj.resh; |
|
this._rescc = obj.rescc; |
|
this._method = obj.m; |
|
this._url = obj.u; |
|
this._host = obj.h; |
|
this._noAuthorization = obj.a; |
|
this._reqHeaders = obj.reqh; |
|
this._reqcc = obj.reqcc; |
|
} |
|
|
|
toObject() { |
|
return { |
|
v: 1, |
|
t: this._responseTime, |
|
sh: this._isShared, |
|
ch: this._cacheHeuristic, |
|
imm: this._immutableMinTtl, |
|
st: this._status, |
|
resh: this._resHeaders, |
|
rescc: this._rescc, |
|
m: this._method, |
|
u: this._url, |
|
h: this._host, |
|
a: this._noAuthorization, |
|
reqh: this._reqHeaders, |
|
reqcc: this._reqcc, |
|
}; |
|
} |
|
|
|
/** |
|
* Headers for sending to the origin server to revalidate stale response. |
|
* Allows server to return 304 to allow reuse of the previous response. |
|
* |
|
* Hop by hop headers are always stripped. |
|
* Revalidation headers may be added or removed, depending on request. |
|
*/ |
|
revalidationHeaders(incomingReq) { |
|
this._assertRequestHasHeaders(incomingReq); |
|
const headers = this._copyWithoutHopByHopHeaders(incomingReq.headers); |
|
|
|
// This implementation does not understand range requests |
|
delete headers['if-range']; |
|
|
|
if (!this._requestMatches(incomingReq, true) || !this.storable()) { |
|
// revalidation allowed via HEAD |
|
// not for the same resource, or wasn't allowed to be cached anyway |
|
delete headers['if-none-match']; |
|
delete headers['if-modified-since']; |
|
return headers; |
|
} |
|
|
|
/* MUST send that entity-tag in any cache validation request (using If-Match or If-None-Match) if an entity-tag has been provided by the origin server. */ |
|
if (this._resHeaders.etag) { |
|
headers['if-none-match'] = headers['if-none-match'] |
|
? `${headers['if-none-match']}, ${this._resHeaders.etag}` |
|
: this._resHeaders.etag; |
|
} |
|
|
|
// Clients MAY issue simple (non-subrange) GET requests with either weak validators or strong validators. Clients MUST NOT use weak validators in other forms of request. |
|
const forbidsWeakValidators = |
|
headers['accept-ranges'] || |
|
headers['if-match'] || |
|
headers['if-unmodified-since'] || |
|
(this._method && this._method != 'GET'); |
|
|
|
/* SHOULD send the Last-Modified value in non-subrange cache validation requests (using If-Modified-Since) if only a Last-Modified value has been provided by the origin server. |
|
Note: This implementation does not understand partial responses (206) */ |
|
if (forbidsWeakValidators) { |
|
delete headers['if-modified-since']; |
|
|
|
if (headers['if-none-match']) { |
|
const etags = headers['if-none-match'] |
|
.split(/,/) |
|
.filter(etag => { |
|
return !/^\s*W\//.test(etag); |
|
}); |
|
if (!etags.length) { |
|
delete headers['if-none-match']; |
|
} else { |
|
headers['if-none-match'] = etags.join(',').trim(); |
|
} |
|
} |
|
} else if ( |
|
this._resHeaders['last-modified'] && |
|
!headers['if-modified-since'] |
|
) { |
|
headers['if-modified-since'] = this._resHeaders['last-modified']; |
|
} |
|
|
|
return headers; |
|
} |
|
|
|
/** |
|
* Creates new CachePolicy with information combined from the previews response, |
|
* and the new revalidation response. |
|
* |
|
* Returns {policy, modified} where modified is a boolean indicating |
|
* whether the response body has been modified, and old cached body can't be used. |
|
* |
|
* @return {Object} {policy: CachePolicy, modified: Boolean} |
|
*/ |
|
revalidatedPolicy(request, response) { |
|
this._assertRequestHasHeaders(request); |
|
if(this._useStaleIfError() && isErrorResponse(response)) { // I consider the revalidation request unsuccessful |
|
return { |
|
modified: false, |
|
matches: false, |
|
policy: this, |
|
}; |
|
} |
|
if (!response || !response.headers) { |
|
throw Error('Response headers missing'); |
|
} |
|
|
|
// These aren't going to be supported exactly, since one CachePolicy object |
|
// doesn't know about all the other cached objects. |
|
let matches = false; |
|
if (response.status !== undefined && response.status != 304) { |
|
matches = false; |
|
} else if ( |
|
response.headers.etag && |
|
!/^\s*W\//.test(response.headers.etag) |
|
) { |
|
// "All of the stored responses with the same strong validator are selected. |
|
// If none of the stored responses contain the same strong validator, |
|
// then the cache MUST NOT use the new response to update any stored responses." |
|
matches = |
|
this._resHeaders.etag && |
|
this._resHeaders.etag.replace(/^\s*W\//, '') === |
|
response.headers.etag; |
|
} else if (this._resHeaders.etag && response.headers.etag) { |
|
// "If the new response contains a weak validator and that validator corresponds |
|
// to one of the cache's stored responses, |
|
// then the most recent of those matching stored responses is selected for update." |
|
matches = |
|
this._resHeaders.etag.replace(/^\s*W\//, '') === |
|
response.headers.etag.replace(/^\s*W\//, ''); |
|
} else if (this._resHeaders['last-modified']) { |
|
matches = |
|
this._resHeaders['last-modified'] === |
|
response.headers['last-modified']; |
|
} else { |
|
// If the new response does not include any form of validator (such as in the case where |
|
// a client generates an If-Modified-Since request from a source other than the Last-Modified |
|
// response header field), and there is only one stored response, and that stored response also |
|
// lacks a validator, then that stored response is selected for update. |
|
if ( |
|
!this._resHeaders.etag && |
|
!this._resHeaders['last-modified'] && |
|
!response.headers.etag && |
|
!response.headers['last-modified'] |
|
) { |
|
matches = true; |
|
} |
|
} |
|
|
|
if (!matches) { |
|
return { |
|
policy: new this.constructor(request, response), |
|
// Client receiving 304 without body, even if it's invalid/mismatched has no option |
|
// but to reuse a cached body. We don't have a good way to tell clients to do |
|
// error recovery in such case. |
|
modified: response.status != 304, |
|
matches: false, |
|
}; |
|
} |
|
|
|
// use other header fields provided in the 304 (Not Modified) response to replace all instances |
|
// of the corresponding header fields in the stored response. |
|
const headers = {}; |
|
for (const k in this._resHeaders) { |
|
headers[k] = |
|
k in response.headers && !excludedFromRevalidationUpdate[k] |
|
? response.headers[k] |
|
: this._resHeaders[k]; |
|
} |
|
|
|
const newResponse = Object.assign({}, response, { |
|
status: this._status, |
|
method: this._method, |
|
headers, |
|
}); |
|
return { |
|
policy: new this.constructor(request, newResponse, { |
|
shared: this._isShared, |
|
cacheHeuristic: this._cacheHeuristic, |
|
immutableMinTimeToLive: this._immutableMinTtl, |
|
}), |
|
modified: false, |
|
matches: true, |
|
}; |
|
} |
|
};
|
|
|