UNPKG

14.8 kBJavaScriptView Raw
1'use strict';
2// rfc7231 6.1
3const statusCodeCacheableByDefault = [200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501];
4
5// This implementation does not understand partial responses (206)
6const understoodStatuses = [200, 203, 204, 300, 301, 302, 303, 307, 308, 404, 405, 410, 414, 501];
7
8const hopByHopHeaders = {'connection':true, 'keep-alive':true, 'proxy-authenticate':true, 'proxy-authorization':true, 'te':true, 'trailers':true, 'transfer-encoding':true, 'upgrade':true};
9
10function parseCacheControl(header) {
11 const cc = {};
12 if (!header) return cc;
13
14 // 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),
15 // the directive's value is considered invalid. Caches are encouraged to consider responses that have invalid freshness information to be stale
16 const parts = header.trim().split(/\s*,\s*/); // TODO: lame parsing
17 for(const part of parts) {
18 const [k,v] = part.split(/\s*=\s*/, 2);
19 cc[k] = (v === undefined) ? true : v.replace(/^"|"$/g, ''); // TODO: lame unquoting
20 }
21
22 return cc;
23}
24
25function formatCacheControl(cc) {
26 let parts = [];
27 for(const k in cc) {
28 const v = cc[k];
29 parts.push(v === true ? k : k + '=' + v);
30 }
31 if (!parts.length) {
32 return undefined;
33 }
34 return parts.join(', ');
35}
36
37module.exports = class CachePolicy {
38 constructor(req, res, {shared, cacheHeuristic, ignoreCargoCult, _fromObject} = {}) {
39 if (_fromObject) {
40 this._fromObject(_fromObject);
41 return;
42 }
43
44 if (!res || !res.headers) {
45 throw Error("Response headers missing");
46 }
47 if (!req || !req.headers) {
48 throw Error("Request headers missing");
49 }
50
51 this._responseTime = this.now();
52 this._isShared = shared !== false;
53 this._cacheHeuristic = undefined !== cacheHeuristic ? cacheHeuristic : 0.1; // 10% matches IE
54
55 this._status = 'status' in res ? res.status : 200;
56 this._resHeaders = res.headers;
57 this._rescc = parseCacheControl(res.headers['cache-control']);
58 this._method = 'method' in req ? req.method : 'GET';
59 this._url = req.url;
60 this._host = req.headers.host;
61 this._noAuthorization = !req.headers.authorization;
62 this._reqHeaders = res.headers.vary ? req.headers : null; // Don't keep all request headers if they won't be used
63 this._reqcc = parseCacheControl(req.headers['cache-control']);
64
65 // Assume that if someone uses legacy, non-standard uncecessary options they don't understand caching,
66 // so there's no point stricly adhering to the blindly copy&pasted directives.
67 if (ignoreCargoCult && "pre-check" in this._rescc && "post-check" in this._rescc) {
68 delete this._rescc['pre-check'];
69 delete this._rescc['post-check'];
70 delete this._rescc['no-cache'];
71 delete this._rescc['no-store'];
72 delete this._rescc['must-revalidate'];
73 this._resHeaders = Object.assign({}, this._resHeaders, {'cache-control': formatCacheControl(this._rescc)});
74 delete this._resHeaders.expires;
75 delete this._resHeaders.pragma;
76 }
77
78 // When the Cache-Control header field is not present in a request, caches MUST consider the no-cache request pragma-directive
79 // as having the same effect as if "Cache-Control: no-cache" were present (see Section 5.2.1).
80 if (!res.headers['cache-control'] && /no-cache/.test(res.headers.pragma)) {
81 this._rescc['no-cache'] = true;
82 }
83 }
84
85 now() {
86 return Date.now();
87 }
88
89 storable() {
90 // The "no-store" request directive indicates that a cache MUST NOT store any part of either this request or any response to it.
91 return !!(!this._reqcc['no-store'] &&
92 // A cache MUST NOT store a response to any request, unless:
93 // The request method is understood by the cache and defined as being cacheable, and
94 ('GET' === this._method || 'HEAD' === this._method || ('POST' === this._method && this._hasExplicitExpiration())) &&
95 // the response status code is understood by the cache, and
96 understoodStatuses.includes(this._status) &&
97 // the "no-store" cache directive does not appear in request or response header fields, and
98 !this._rescc['no-store'] &&
99 // the "private" response directive does not appear in the response, if the cache is shared, and
100 (!this._isShared || !this._rescc.private) &&
101 // the Authorization header field does not appear in the request, if the cache is shared,
102 (!this._isShared || this._noAuthorization || this._allowsStoringAuthenticated()) &&
103 // the response either:
104 (
105 // contains an Expires header field, or
106 this._resHeaders.expires ||
107 // contains a max-age response directive, or
108 // contains a s-maxage response directive and the cache is shared, or
109 // contains a public response directive.
110 this._rescc.public || this._rescc['max-age'] || this._rescc['s-maxage'] ||
111 // has a status code that is defined as cacheable by default
112 statusCodeCacheableByDefault.includes(this._status)
113 ));
114 }
115
116 _hasExplicitExpiration() {
117 // 4.2.1 Calculating Freshness Lifetime
118 return (this._isShared && this._rescc['s-maxage']) ||
119 this._rescc['max-age'] ||
120 this._resHeaders.expires;
121 }
122
123 _assertRequestHasHeaders(req) {
124 if (!req || !req.headers) {
125 throw Error("Request headers missing");
126 }
127 }
128
129 satisfiesWithoutRevalidation(req) {
130 this._assertRequestHasHeaders(req);
131
132 // When presented with a request, a cache MUST NOT reuse a stored response, unless:
133 // the presented request does not contain the no-cache pragma (Section 5.4), nor the no-cache cache directive,
134 // unless the stored response is successfully validated (Section 4.3), and
135 const requestCC = parseCacheControl(req.headers['cache-control']);
136 if (requestCC['no-cache'] || /no-cache/.test(req.headers.pragma)) {
137 return false;
138 }
139
140 if (requestCC['max-age'] && this.age() > requestCC['max-age']) {
141 return false;
142 }
143
144 if (requestCC['min-fresh'] && this.timeToLive() < 1000*requestCC['min-fresh']) {
145 return false;
146 }
147
148 // the stored response is either:
149 // fresh, or allowed to be served stale
150 if (this.stale()) {
151 const allowsStale = requestCC['max-stale'] && !this._rescc['must-revalidate'] && (true === requestCC['max-stale'] || requestCC['max-stale'] > this.age() - this.maxAge());
152 if (!allowsStale) {
153 return false;
154 }
155 }
156
157 return this._requestMatches(req, false);
158 }
159
160 _requestMatches(req, allowHeadMethod) {
161 // The presented effective request URI and that of the stored response match, and
162 return (!this._url || this._url === req.url) &&
163 (this._host === req.headers.host) &&
164 // the request method associated with the stored response allows it to be used for the presented request, and
165 (!req.method || this._method === req.method || (allowHeadMethod && 'HEAD' === req.method)) &&
166 // selecting header fields nominated by the stored response (if any) match those presented, and
167 this._varyMatches(req);
168 }
169
170 _allowsStoringAuthenticated() {
171 // following Cache-Control response directives (Section 5.2.2) have such an effect: must-revalidate, public, and s-maxage.
172 return this._rescc['must-revalidate'] || this._rescc.public || this._rescc['s-maxage'];
173 }
174
175 _varyMatches(req) {
176 if (!this._resHeaders.vary) {
177 return true;
178 }
179
180 // A Vary header field-value of "*" always fails to match
181 if (this._resHeaders.vary === '*') {
182 return false;
183 }
184
185 const fields = this._resHeaders.vary.trim().toLowerCase().split(/\s*,\s*/);
186 for(const name of fields) {
187 if (req.headers[name] !== this._reqHeaders[name]) return false;
188 }
189 return true;
190 }
191
192 responseHeaders() {
193 const headers = {};
194 for(const name in this._resHeaders) {
195 if (hopByHopHeaders[name]) continue;
196 headers[name] = this._resHeaders[name];
197 }
198 // 9.1. Connection
199 if (this._resHeaders.connection) {
200 const tokens = this._resHeaders.connection.trim().split(/\s*,\s*/);
201 for(const name of tokens) {
202 delete headers[name];
203 }
204 }
205 if (headers.warning) {
206 const warnings = headers.warning.split(/,/).filter(warning => {
207 return !/^\s*1[0-9][0-9]/.test(warning);
208 });
209 if (!warnings.length) {
210 delete headers.warning;
211 } else {
212 headers.warning = warnings.join(',').trim();
213 }
214 }
215 headers.age = `${Math.round(this.age())}`;
216 return headers;
217 }
218
219 /**
220 * Value of the Date response header or current time if Date was demed invalid
221 * @return timestamp
222 */
223 date() {
224 const dateValue = Date.parse(this._resHeaders.date)
225 const maxClockDrift = 8*3600*1000;
226 if (Number.isNaN(dateValue) || dateValue < this._responseTime-maxClockDrift || dateValue > this._responseTime+maxClockDrift) {
227 return this._responseTime;
228 }
229 return dateValue;
230 }
231
232 /**
233 * Value of the Age header, in seconds, updated for the current time.
234 * May be fractional.
235 *
236 * @return Number
237 */
238 age() {
239 let age = Math.max(0, (this._responseTime - this.date())/1000);
240 if (this._resHeaders.age) {
241 let ageValue = this._ageValue();
242 if (ageValue > age) age = ageValue;
243 }
244
245 const residentTime = (this.now() - this._responseTime)/1000;
246 return age + residentTime;
247 }
248
249 _ageValue() {
250 const ageValue = parseInt(this._resHeaders.age);
251 return isFinite(ageValue) ? ageValue : 0;
252 }
253
254 maxAge() {
255 if (!this.storable() || this._rescc['no-cache']) {
256 return 0;
257 }
258
259 // Shared responses with cookies are cacheable according to the RFC, but IMHO it'd be unwise to do so by default
260 // so this implementation requires explicit opt-in via public header
261 if (this._isShared && (this._resHeaders['set-cookie'] && !this._rescc.public)) {
262 return 0;
263 }
264
265 if (this._resHeaders.vary === '*') {
266 return 0;
267 }
268
269 if (this._isShared) {
270 if (this._rescc['proxy-revalidate']) {
271 return 0;
272 }
273 // if a response includes the s-maxage directive, a shared cache recipient MUST ignore the Expires field.
274 if (this._rescc['s-maxage']) {
275 return parseInt(this._rescc['s-maxage'], 10);
276 }
277 }
278
279 // If a response includes a Cache-Control field with the max-age directive, a recipient MUST ignore the Expires field.
280 if (this._rescc['max-age']) {
281 return parseInt(this._rescc['max-age'], 10);
282 }
283
284 const dateValue = this.date();
285 if (this._resHeaders.expires) {
286 const expires = Date.parse(this._resHeaders.expires);
287 // A cache recipient MUST interpret invalid date formats, especially the value "0", as representing a time in the past (i.e., "already expired").
288 if (Number.isNaN(expires) || expires < dateValue) {
289 return 0;
290 }
291 return (expires - dateValue)/1000;
292 }
293
294 if (this._resHeaders['last-modified']) {
295 const lastModified = Date.parse(this._resHeaders['last-modified']);
296 if (isFinite(lastModified) && dateValue > lastModified) {
297 return (dateValue - lastModified)/1000 * this._cacheHeuristic;
298 }
299 }
300 return 0;
301 }
302
303 timeToLive() {
304 return Math.max(0, this.maxAge() - this.age())*1000;
305 }
306
307 stale() {
308 return this.maxAge() <= this.age();
309 }
310
311 static fromObject(obj) {
312 return new this(undefined, undefined, {_fromObject:obj});
313 }
314
315 _fromObject(obj) {
316 if (this._responseTime) throw Error("Reinitialized");
317 if (!obj || obj.v !== 1) throw Error("Invalid serialization");
318
319 this._responseTime = obj.t;
320 this._isShared = obj.sh;
321 this._cacheHeuristic = obj.ch;
322 this._status = obj.st;
323 this._resHeaders = obj.resh;
324 this._rescc = obj.rescc;
325 this._method = obj.m;
326 this._url = obj.u;
327 this._host = obj.h;
328 this._noAuthorization = obj.a;
329 this._reqHeaders = obj.reqh;
330 this._reqcc = obj.reqcc;
331 }
332
333 toObject() {
334 return {
335 v:1,
336 t: this._responseTime,
337 sh: this._isShared,
338 ch: this._cacheHeuristic,
339 st: this._status,
340 resh: this._resHeaders,
341 rescc: this._rescc,
342 m: this._method,
343 u: this._url,
344 h: this._host,
345 a: this._noAuthorization,
346 reqh: this._reqHeaders,
347 reqcc: this._reqcc,
348 };
349 }
350
351 revalidationHeaders(incoming_req) {
352 this._assertRequestHasHeaders(incoming_req);
353 if (!this._resHeaders.etag && !this._resHeaders['last-modified']) {
354 return incoming_req.headers; // no validators available
355 }
356 // revalidation allowed via HEAD
357 if (!this._requestMatches(incoming_req, true)) {
358 return incoming_req.headers; // not for the same resource
359 }
360
361 const headers = Object.assign({}, incoming_req.headers);
362
363 /* 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. */
364 if (this._resHeaders.etag) {
365 headers['if-none-match'] = this._resHeaders.etag;
366 }
367 /* 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.
368 Note: This implementation does not understand partial responses (206) */
369 if (this._resHeaders['last-modified'] && this.storable()) {
370 headers['if-modified-since'] = this._resHeaders['last-modified'];
371 }
372
373 return headers;
374 }
375};