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