• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1'use strict';
2// rfc7231 6.1
3
4function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
5
6var statusCodeCacheableByDefault = [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501];
7
8// This implementation does not understand partial responses (206)
9var understoodStatuses = [200, 203, 204, 300, 301, 302, 303, 307, 308, 404, 405, 410, 414, 501];
10
11var hopByHopHeaders = { 'connection': true, 'keep-alive': true, 'proxy-authenticate': true, 'proxy-authorization': true, 'te': true, 'trailer': true, 'transfer-encoding': true, 'upgrade': true };
12var excludedFromRevalidationUpdate = {
13    // Since the old body is reused, it doesn't make sense to change properties of the body
14    'content-length': true, 'content-encoding': true, 'transfer-encoding': true,
15    'content-range': true
16};
17
18function parseCacheControl(header) {
19    var cc = {};
20    if (!header) return cc;
21
22    // 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),
23    // the directive's value is considered invalid. Caches are encouraged to consider responses that have invalid freshness information to be stale
24    var parts = header.trim().split(/\s*,\s*/); // TODO: lame parsing
25    for (var _iterator = parts, _isArray = Array.isArray(_iterator), _i = 0, _iterator = _isArray ? _iterator : _iterator[Symbol.iterator]();;) {
26        var _ref;
27
28        if (_isArray) {
29            if (_i >= _iterator.length) break;
30            _ref = _iterator[_i++];
31        } else {
32            _i = _iterator.next();
33            if (_i.done) break;
34            _ref = _i.value;
35        }
36
37        var part = _ref;
38
39        var _part$split = part.split(/\s*=\s*/, 2),
40            k = _part$split[0],
41            v = _part$split[1];
42
43        cc[k] = v === undefined ? true : v.replace(/^"|"$/g, ''); // TODO: lame unquoting
44    }
45
46    return cc;
47}
48
49function formatCacheControl(cc) {
50    var parts = [];
51    for (var k in cc) {
52        var v = cc[k];
53        parts.push(v === true ? k : k + '=' + v);
54    }
55    if (!parts.length) {
56        return undefined;
57    }
58    return parts.join(', ');
59}
60
61module.exports = function () {
62    function CachePolicy(req, res) {
63        var _ref2 = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {},
64            shared = _ref2.shared,
65            cacheHeuristic = _ref2.cacheHeuristic,
66            immutableMinTimeToLive = _ref2.immutableMinTimeToLive,
67            ignoreCargoCult = _ref2.ignoreCargoCult,
68            _fromObject = _ref2._fromObject;
69
70        _classCallCheck(this, CachePolicy);
71
72        if (_fromObject) {
73            this._fromObject(_fromObject);
74            return;
75        }
76
77        if (!res || !res.headers) {
78            throw Error("Response headers missing");
79        }
80        this._assertRequestHasHeaders(req);
81
82        this._responseTime = this.now();
83        this._isShared = shared !== false;
84        this._cacheHeuristic = undefined !== cacheHeuristic ? cacheHeuristic : 0.1; // 10% matches IE
85        this._immutableMinTtl = undefined !== immutableMinTimeToLive ? immutableMinTimeToLive : 24 * 3600 * 1000;
86
87        this._status = 'status' in res ? res.status : 200;
88        this._resHeaders = res.headers;
89        this._rescc = parseCacheControl(res.headers['cache-control']);
90        this._method = 'method' in req ? req.method : 'GET';
91        this._url = req.url;
92        this._host = req.headers.host;
93        this._noAuthorization = !req.headers.authorization;
94        this._reqHeaders = res.headers.vary ? req.headers : null; // Don't keep all request headers if they won't be used
95        this._reqcc = parseCacheControl(req.headers['cache-control']);
96
97        // Assume that if someone uses legacy, non-standard uncecessary options they don't understand caching,
98        // so there's no point stricly adhering to the blindly copy&pasted directives.
99        if (ignoreCargoCult && "pre-check" in this._rescc && "post-check" in this._rescc) {
100            delete this._rescc['pre-check'];
101            delete this._rescc['post-check'];
102            delete this._rescc['no-cache'];
103            delete this._rescc['no-store'];
104            delete this._rescc['must-revalidate'];
105            this._resHeaders = Object.assign({}, this._resHeaders, { 'cache-control': formatCacheControl(this._rescc) });
106            delete this._resHeaders.expires;
107            delete this._resHeaders.pragma;
108        }
109
110        // When the Cache-Control header field is not present in a request, caches MUST consider the no-cache request pragma-directive
111        // as having the same effect as if "Cache-Control: no-cache" were present (see Section 5.2.1).
112        if (!res.headers['cache-control'] && /no-cache/.test(res.headers.pragma)) {
113            this._rescc['no-cache'] = true;
114        }
115    }
116
117    CachePolicy.prototype.now = function now() {
118        return Date.now();
119    };
120
121    CachePolicy.prototype.storable = function storable() {
122        // The "no-store" request directive indicates that a cache MUST NOT store any part of either this request or any response to it.
123        return !!(!this._reqcc['no-store'] && (
124        // A cache MUST NOT store a response to any request, unless:
125        // The request method is understood by the cache and defined as being cacheable, and
126        'GET' === this._method || 'HEAD' === this._method || 'POST' === this._method && this._hasExplicitExpiration()) &&
127        // the response status code is understood by the cache, and
128        understoodStatuses.indexOf(this._status) !== -1 &&
129        // the "no-store" cache directive does not appear in request or response header fields, and
130        !this._rescc['no-store'] && (
131        // the "private" response directive does not appear in the response, if the cache is shared, and
132        !this._isShared || !this._rescc.private) && (
133        // the Authorization header field does not appear in the request, if the cache is shared,
134        !this._isShared || this._noAuthorization || this._allowsStoringAuthenticated()) && (
135        // the response either:
136
137        // contains an Expires header field, or
138        this._resHeaders.expires ||
139        // contains a max-age response directive, or
140        // contains a s-maxage response directive and the cache is shared, or
141        // contains a public response directive.
142        this._rescc.public || this._rescc['max-age'] || this._rescc['s-maxage'] ||
143        // has a status code that is defined as cacheable by default
144        statusCodeCacheableByDefault.indexOf(this._status) !== -1));
145    };
146
147    CachePolicy.prototype._hasExplicitExpiration = function _hasExplicitExpiration() {
148        // 4.2.1 Calculating Freshness Lifetime
149        return this._isShared && this._rescc['s-maxage'] || this._rescc['max-age'] || this._resHeaders.expires;
150    };
151
152    CachePolicy.prototype._assertRequestHasHeaders = function _assertRequestHasHeaders(req) {
153        if (!req || !req.headers) {
154            throw Error("Request headers missing");
155        }
156    };
157
158    CachePolicy.prototype.satisfiesWithoutRevalidation = function satisfiesWithoutRevalidation(req) {
159        this._assertRequestHasHeaders(req);
160
161        // When presented with a request, a cache MUST NOT reuse a stored response, unless:
162        // the presented request does not contain the no-cache pragma (Section 5.4), nor the no-cache cache directive,
163        // unless the stored response is successfully validated (Section 4.3), and
164        var requestCC = parseCacheControl(req.headers['cache-control']);
165        if (requestCC['no-cache'] || /no-cache/.test(req.headers.pragma)) {
166            return false;
167        }
168
169        if (requestCC['max-age'] && this.age() > requestCC['max-age']) {
170            return false;
171        }
172
173        if (requestCC['min-fresh'] && this.timeToLive() < 1000 * requestCC['min-fresh']) {
174            return false;
175        }
176
177        // the stored response is either:
178        // fresh, or allowed to be served stale
179        if (this.stale()) {
180            var allowsStale = requestCC['max-stale'] && !this._rescc['must-revalidate'] && (true === requestCC['max-stale'] || requestCC['max-stale'] > this.age() - this.maxAge());
181            if (!allowsStale) {
182                return false;
183            }
184        }
185
186        return this._requestMatches(req, false);
187    };
188
189    CachePolicy.prototype._requestMatches = function _requestMatches(req, allowHeadMethod) {
190        // The presented effective request URI and that of the stored response match, and
191        return (!this._url || this._url === req.url) && this._host === req.headers.host && (
192        // the request method associated with the stored response allows it to be used for the presented request, and
193        !req.method || this._method === req.method || allowHeadMethod && 'HEAD' === req.method) &&
194        // selecting header fields nominated by the stored response (if any) match those presented, and
195        this._varyMatches(req);
196    };
197
198    CachePolicy.prototype._allowsStoringAuthenticated = function _allowsStoringAuthenticated() {
199        //  following Cache-Control response directives (Section 5.2.2) have such an effect: must-revalidate, public, and s-maxage.
200        return this._rescc['must-revalidate'] || this._rescc.public || this._rescc['s-maxage'];
201    };
202
203    CachePolicy.prototype._varyMatches = function _varyMatches(req) {
204        if (!this._resHeaders.vary) {
205            return true;
206        }
207
208        // A Vary header field-value of "*" always fails to match
209        if (this._resHeaders.vary === '*') {
210            return false;
211        }
212
213        var fields = this._resHeaders.vary.trim().toLowerCase().split(/\s*,\s*/);
214        for (var _iterator2 = fields, _isArray2 = Array.isArray(_iterator2), _i2 = 0, _iterator2 = _isArray2 ? _iterator2 : _iterator2[Symbol.iterator]();;) {
215            var _ref3;
216
217            if (_isArray2) {
218                if (_i2 >= _iterator2.length) break;
219                _ref3 = _iterator2[_i2++];
220            } else {
221                _i2 = _iterator2.next();
222                if (_i2.done) break;
223                _ref3 = _i2.value;
224            }
225
226            var name = _ref3;
227
228            if (req.headers[name] !== this._reqHeaders[name]) return false;
229        }
230        return true;
231    };
232
233    CachePolicy.prototype._copyWithoutHopByHopHeaders = function _copyWithoutHopByHopHeaders(inHeaders) {
234        var headers = {};
235        for (var name in inHeaders) {
236            if (hopByHopHeaders[name]) continue;
237            headers[name] = inHeaders[name];
238        }
239        // 9.1.  Connection
240        if (inHeaders.connection) {
241            var tokens = inHeaders.connection.trim().split(/\s*,\s*/);
242            for (var _iterator3 = tokens, _isArray3 = Array.isArray(_iterator3), _i3 = 0, _iterator3 = _isArray3 ? _iterator3 : _iterator3[Symbol.iterator]();;) {
243                var _ref4;
244
245                if (_isArray3) {
246                    if (_i3 >= _iterator3.length) break;
247                    _ref4 = _iterator3[_i3++];
248                } else {
249                    _i3 = _iterator3.next();
250                    if (_i3.done) break;
251                    _ref4 = _i3.value;
252                }
253
254                var _name = _ref4;
255
256                delete headers[_name];
257            }
258        }
259        if (headers.warning) {
260            var warnings = headers.warning.split(/,/).filter(function (warning) {
261                return !/^\s*1[0-9][0-9]/.test(warning);
262            });
263            if (!warnings.length) {
264                delete headers.warning;
265            } else {
266                headers.warning = warnings.join(',').trim();
267            }
268        }
269        return headers;
270    };
271
272    CachePolicy.prototype.responseHeaders = function responseHeaders() {
273        var headers = this._copyWithoutHopByHopHeaders(this._resHeaders);
274        var age = this.age();
275
276        // A cache SHOULD generate 113 warning if it heuristically chose a freshness
277        // lifetime greater than 24 hours and the response's age is greater than 24 hours.
278        if (age > 3600 * 24 && !this._hasExplicitExpiration() && this.maxAge() > 3600 * 24) {
279            headers.warning = (headers.warning ? `${headers.warning}, ` : '') + '113 - "rfc7234 5.5.4"';
280        }
281        headers.age = `${Math.round(age)}`;
282        return headers;
283    };
284
285    /**
286     * Value of the Date response header or current time if Date was demed invalid
287     * @return timestamp
288     */
289
290
291    CachePolicy.prototype.date = function date() {
292        var dateValue = Date.parse(this._resHeaders.date);
293        var maxClockDrift = 8 * 3600 * 1000;
294        if (Number.isNaN(dateValue) || dateValue < this._responseTime - maxClockDrift || dateValue > this._responseTime + maxClockDrift) {
295            return this._responseTime;
296        }
297        return dateValue;
298    };
299
300    /**
301     * Value of the Age header, in seconds, updated for the current time.
302     * May be fractional.
303     *
304     * @return Number
305     */
306
307
308    CachePolicy.prototype.age = function age() {
309        var age = Math.max(0, (this._responseTime - this.date()) / 1000);
310        if (this._resHeaders.age) {
311            var ageValue = this._ageValue();
312            if (ageValue > age) age = ageValue;
313        }
314
315        var residentTime = (this.now() - this._responseTime) / 1000;
316        return age + residentTime;
317    };
318
319    CachePolicy.prototype._ageValue = function _ageValue() {
320        var ageValue = parseInt(this._resHeaders.age);
321        return isFinite(ageValue) ? ageValue : 0;
322    };
323
324    /**
325     * Value of applicable max-age (or heuristic equivalent) in seconds. This counts since response's `Date`.
326     *
327     * For an up-to-date value, see `timeToLive()`.
328     *
329     * @return Number
330     */
331
332
333    CachePolicy.prototype.maxAge = function maxAge() {
334        if (!this.storable() || this._rescc['no-cache']) {
335            return 0;
336        }
337
338        // Shared responses with cookies are cacheable according to the RFC, but IMHO it'd be unwise to do so by default
339        // so this implementation requires explicit opt-in via public header
340        if (this._isShared && this._resHeaders['set-cookie'] && !this._rescc.public && !this._rescc.immutable) {
341            return 0;
342        }
343
344        if (this._resHeaders.vary === '*') {
345            return 0;
346        }
347
348        if (this._isShared) {
349            if (this._rescc['proxy-revalidate']) {
350                return 0;
351            }
352            // if a response includes the s-maxage directive, a shared cache recipient MUST ignore the Expires field.
353            if (this._rescc['s-maxage']) {
354                return parseInt(this._rescc['s-maxage'], 10);
355            }
356        }
357
358        // If a response includes a Cache-Control field with the max-age directive, a recipient MUST ignore the Expires field.
359        if (this._rescc['max-age']) {
360            return parseInt(this._rescc['max-age'], 10);
361        }
362
363        var defaultMinTtl = this._rescc.immutable ? this._immutableMinTtl : 0;
364
365        var dateValue = this.date();
366        if (this._resHeaders.expires) {
367            var expires = Date.parse(this._resHeaders.expires);
368            // A cache recipient MUST interpret invalid date formats, especially the value "0", as representing a time in the past (i.e., "already expired").
369            if (Number.isNaN(expires) || expires < dateValue) {
370                return 0;
371            }
372            return Math.max(defaultMinTtl, (expires - dateValue) / 1000);
373        }
374
375        if (this._resHeaders['last-modified']) {
376            var lastModified = Date.parse(this._resHeaders['last-modified']);
377            if (isFinite(lastModified) && dateValue > lastModified) {
378                return Math.max(defaultMinTtl, (dateValue - lastModified) / 1000 * this._cacheHeuristic);
379            }
380        }
381
382        return defaultMinTtl;
383    };
384
385    CachePolicy.prototype.timeToLive = function timeToLive() {
386        return Math.max(0, this.maxAge() - this.age()) * 1000;
387    };
388
389    CachePolicy.prototype.stale = function stale() {
390        return this.maxAge() <= this.age();
391    };
392
393    CachePolicy.fromObject = function fromObject(obj) {
394        return new this(undefined, undefined, { _fromObject: obj });
395    };
396
397    CachePolicy.prototype._fromObject = function _fromObject(obj) {
398        if (this._responseTime) throw Error("Reinitialized");
399        if (!obj || obj.v !== 1) throw Error("Invalid serialization");
400
401        this._responseTime = obj.t;
402        this._isShared = obj.sh;
403        this._cacheHeuristic = obj.ch;
404        this._immutableMinTtl = obj.imm !== undefined ? obj.imm : 24 * 3600 * 1000;
405        this._status = obj.st;
406        this._resHeaders = obj.resh;
407        this._rescc = obj.rescc;
408        this._method = obj.m;
409        this._url = obj.u;
410        this._host = obj.h;
411        this._noAuthorization = obj.a;
412        this._reqHeaders = obj.reqh;
413        this._reqcc = obj.reqcc;
414    };
415
416    CachePolicy.prototype.toObject = function toObject() {
417        return {
418            v: 1,
419            t: this._responseTime,
420            sh: this._isShared,
421            ch: this._cacheHeuristic,
422            imm: this._immutableMinTtl,
423            st: this._status,
424            resh: this._resHeaders,
425            rescc: this._rescc,
426            m: this._method,
427            u: this._url,
428            h: this._host,
429            a: this._noAuthorization,
430            reqh: this._reqHeaders,
431            reqcc: this._reqcc
432        };
433    };
434
435    /**
436     * Headers for sending to the origin server to revalidate stale response.
437     * Allows server to return 304 to allow reuse of the previous response.
438     *
439     * Hop by hop headers are always stripped.
440     * Revalidation headers may be added or removed, depending on request.
441     */
442
443
444    CachePolicy.prototype.revalidationHeaders = function revalidationHeaders(incomingReq) {
445        this._assertRequestHasHeaders(incomingReq);
446        var headers = this._copyWithoutHopByHopHeaders(incomingReq.headers);
447
448        // This implementation does not understand range requests
449        delete headers['if-range'];
450
451        if (!this._requestMatches(incomingReq, true) || !this.storable()) {
452            // revalidation allowed via HEAD
453            // not for the same resource, or wasn't allowed to be cached anyway
454            delete headers['if-none-match'];
455            delete headers['if-modified-since'];
456            return headers;
457        }
458
459        /* 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. */
460        if (this._resHeaders.etag) {
461            headers['if-none-match'] = headers['if-none-match'] ? `${headers['if-none-match']}, ${this._resHeaders.etag}` : this._resHeaders.etag;
462        }
463
464        // 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.
465        var forbidsWeakValidators = headers['accept-ranges'] || headers['if-match'] || headers['if-unmodified-since'] || this._method && this._method != 'GET';
466
467        /* 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.
468        Note: This implementation does not understand partial responses (206) */
469        if (forbidsWeakValidators) {
470            delete headers['if-modified-since'];
471
472            if (headers['if-none-match']) {
473                var etags = headers['if-none-match'].split(/,/).filter(function (etag) {
474                    return !/^\s*W\//.test(etag);
475                });
476                if (!etags.length) {
477                    delete headers['if-none-match'];
478                } else {
479                    headers['if-none-match'] = etags.join(',').trim();
480                }
481            }
482        } else if (this._resHeaders['last-modified'] && !headers['if-modified-since']) {
483            headers['if-modified-since'] = this._resHeaders['last-modified'];
484        }
485
486        return headers;
487    };
488
489    /**
490     * Creates new CachePolicy with information combined from the previews response,
491     * and the new revalidation response.
492     *
493     * Returns {policy, modified} where modified is a boolean indicating
494     * whether the response body has been modified, and old cached body can't be used.
495     *
496     * @return {Object} {policy: CachePolicy, modified: Boolean}
497     */
498
499
500    CachePolicy.prototype.revalidatedPolicy = function revalidatedPolicy(request, response) {
501        this._assertRequestHasHeaders(request);
502        if (!response || !response.headers) {
503            throw Error("Response headers missing");
504        }
505
506        // These aren't going to be supported exactly, since one CachePolicy object
507        // doesn't know about all the other cached objects.
508        var matches = false;
509        if (response.status !== undefined && response.status != 304) {
510            matches = false;
511        } else if (response.headers.etag && !/^\s*W\//.test(response.headers.etag)) {
512            // "All of the stored responses with the same strong validator are selected.
513            // If none of the stored responses contain the same strong validator,
514            // then the cache MUST NOT use the new response to update any stored responses."
515            matches = this._resHeaders.etag && this._resHeaders.etag.replace(/^\s*W\//, '') === response.headers.etag;
516        } else if (this._resHeaders.etag && response.headers.etag) {
517            // "If the new response contains a weak validator and that validator corresponds
518            // to one of the cache's stored responses,
519            // then the most recent of those matching stored responses is selected for update."
520            matches = this._resHeaders.etag.replace(/^\s*W\//, '') === response.headers.etag.replace(/^\s*W\//, '');
521        } else if (this._resHeaders['last-modified']) {
522            matches = this._resHeaders['last-modified'] === response.headers['last-modified'];
523        } else {
524            // If the new response does not include any form of validator (such as in the case where
525            // a client generates an If-Modified-Since request from a source other than the Last-Modified
526            // response header field), and there is only one stored response, and that stored response also
527            // lacks a validator, then that stored response is selected for update.
528            if (!this._resHeaders.etag && !this._resHeaders['last-modified'] && !response.headers.etag && !response.headers['last-modified']) {
529                matches = true;
530            }
531        }
532
533        if (!matches) {
534            return {
535                policy: new this.constructor(request, response),
536                modified: true
537            };
538        }
539
540        // use other header fields provided in the 304 (Not Modified) response to replace all instances
541        // of the corresponding header fields in the stored response.
542        var headers = {};
543        for (var k in this._resHeaders) {
544            headers[k] = k in response.headers && !excludedFromRevalidationUpdate[k] ? response.headers[k] : this._resHeaders[k];
545        }
546
547        var newResponse = Object.assign({}, response, {
548            status: this._status,
549            method: this._method,
550            headers
551        });
552        return {
553            policy: new this.constructor(request, newResponse),
554            modified: false
555        };
556    };
557
558    return CachePolicy;
559}();