Skip to content
Permalink
ffd96b38fb
Switch branches/tags

Name already in use

A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Go to file
Robert Brignull replace jest with ava
Latest commit 0347b72 May 4, 2020 History
0 contributors

Users who have contributed to this file

673 lines (605 sloc) 23.2 KB
'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,
};
}
};