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