UNPKG

21.3 kBJavaScriptView Raw
1this.workbox = this.workbox || {};
2this.workbox.expiration = (function (exports, DBWrapper_mjs, deleteDatabase_mjs, WorkboxError_mjs, assert_mjs, logger_mjs, cacheNames_mjs, getFriendlyURL_mjs, registerQuotaErrorCallback_mjs) {
3 'use strict';
4
5 try {
6 self['workbox:expiration:4.3.0'] && _();
7 } catch (e) {} // eslint-disable-line
8
9 /*
10 Copyright 2018 Google LLC
11
12 Use of this source code is governed by an MIT-style
13 license that can be found in the LICENSE file or at
14 https://opensource.org/licenses/MIT.
15 */
16 const DB_NAME = 'workbox-expiration';
17 const OBJECT_STORE_NAME = 'cache-entries';
18
19 const normalizeURL = unNormalizedUrl => {
20 const url = new URL(unNormalizedUrl, location);
21 url.hash = '';
22 return url.href;
23 };
24 /**
25 * Returns the timestamp model.
26 *
27 * @private
28 */
29
30
31 class CacheTimestampsModel {
32 /**
33 *
34 * @param {string} cacheName
35 *
36 * @private
37 */
38 constructor(cacheName) {
39 this._cacheName = cacheName;
40 this._db = new DBWrapper_mjs.DBWrapper(DB_NAME, 1, {
41 onupgradeneeded: event => this._handleUpgrade(event)
42 });
43 }
44 /**
45 * Should perform an upgrade of indexedDB.
46 *
47 * @param {Event} event
48 *
49 * @private
50 */
51
52
53 _handleUpgrade(event) {
54 const db = event.target.result; // TODO(philipwalton): EdgeHTML doesn't support arrays as a keyPath, so we
55 // have to use the `id` keyPath here and create our own values (a
56 // concatenation of `url + cacheName`) instead of simply using
57 // `keyPath: ['url', 'cacheName']`, which is supported in other browsers.
58
59 const objStore = db.createObjectStore(OBJECT_STORE_NAME, {
60 keyPath: 'id'
61 }); // TODO(philipwalton): once we don't have to support EdgeHTML, we can
62 // create a single index with the keyPath `['cacheName', 'timestamp']`
63 // instead of doing both these indexes.
64
65 objStore.createIndex('cacheName', 'cacheName', {
66 unique: false
67 });
68 objStore.createIndex('timestamp', 'timestamp', {
69 unique: false
70 }); // Previous versions of `workbox-expiration` used `this._cacheName`
71 // as the IDBDatabase name.
72
73 deleteDatabase_mjs.deleteDatabase(this._cacheName);
74 }
75 /**
76 * @param {string} url
77 * @param {number} timestamp
78 *
79 * @private
80 */
81
82
83 async setTimestamp(url, timestamp) {
84 url = normalizeURL(url);
85 await this._db.put(OBJECT_STORE_NAME, {
86 url,
87 timestamp,
88 cacheName: this._cacheName,
89 // Creating an ID from the URL and cache name won't be necessary once
90 // Edge switches to Chromium and all browsers we support work with
91 // array keyPaths.
92 id: this._getId(url)
93 });
94 }
95 /**
96 * Returns the timestamp stored for a given URL.
97 *
98 * @param {string} url
99 * @return {number}
100 *
101 * @private
102 */
103
104
105 async getTimestamp(url) {
106 const entry = await this._db.get(OBJECT_STORE_NAME, this._getId(url));
107 return entry.timestamp;
108 }
109 /**
110 * Iterates through all the entries in the object store (from newest to
111 * oldest) and removes entries once either `maxCount` is reached or the
112 * entry's timestamp is less than `minTimestamp`.
113 *
114 * @param {number} minTimestamp
115 * @param {number} maxCount
116 *
117 * @private
118 */
119
120
121 async expireEntries(minTimestamp, maxCount) {
122 const entriesToDelete = await this._db.transaction(OBJECT_STORE_NAME, 'readwrite', (txn, done) => {
123 const store = txn.objectStore(OBJECT_STORE_NAME);
124 const entriesToDelete = [];
125 let entriesNotDeletedCount = 0;
126
127 store.index('timestamp').openCursor(null, 'prev').onsuccess = ({
128 target
129 }) => {
130 const cursor = target.result;
131
132 if (cursor) {
133 const result = cursor.value; // TODO(philipwalton): once we can use a multi-key index, we
134 // won't have to check `cacheName` here.
135
136 if (result.cacheName === this._cacheName) {
137 // Delete an entry if it's older than the max age or
138 // if we already have the max number allowed.
139 if (minTimestamp && result.timestamp < minTimestamp || maxCount && entriesNotDeletedCount >= maxCount) {
140 // TODO(philipwalton): we should be able to delete the
141 // entry right here, but doing so causes an iteration
142 // bug in Safari stable (fixed in TP). Instead we can
143 // store the keys of the entries to delete, and then
144 // delete the separate transactions.
145 // https://github.com/GoogleChrome/workbox/issues/1978
146 // cursor.delete();
147 // We only need to return the URL, not the whole entry.
148 entriesToDelete.push(cursor.value);
149 } else {
150 entriesNotDeletedCount++;
151 }
152 }
153
154 cursor.continue();
155 } else {
156 done(entriesToDelete);
157 }
158 };
159 }); // TODO(philipwalton): once the Safari bug in the following issue is fixed,
160 // we should be able to remove this loop and do the entry deletion in the
161 // cursor loop above:
162 // https://github.com/GoogleChrome/workbox/issues/1978
163
164 const urlsDeleted = [];
165
166 for (const entry of entriesToDelete) {
167 await this._db.delete(OBJECT_STORE_NAME, entry.id);
168 urlsDeleted.push(entry.url);
169 }
170
171 return urlsDeleted;
172 }
173 /**
174 * Takes a URL and returns an ID that will be unique in the object store.
175 *
176 * @param {string} url
177 * @return {string}
178 *
179 * @private
180 */
181
182
183 _getId(url) {
184 // Creating an ID from the URL and cache name won't be necessary once
185 // Edge switches to Chromium and all browsers we support work with
186 // array keyPaths.
187 return this._cacheName + '|' + normalizeURL(url);
188 }
189
190 }
191
192 /*
193 Copyright 2018 Google LLC
194
195 Use of this source code is governed by an MIT-style
196 license that can be found in the LICENSE file or at
197 https://opensource.org/licenses/MIT.
198 */
199 /**
200 * The `CacheExpiration` class allows you define an expiration and / or
201 * limit on the number of responses stored in a
202 * [`Cache`](https://developer.mozilla.org/en-US/docs/Web/API/Cache).
203 *
204 * @memberof workbox.expiration
205 */
206
207 class CacheExpiration {
208 /**
209 * To construct a new CacheExpiration instance you must provide at least
210 * one of the `config` properties.
211 *
212 * @param {string} cacheName Name of the cache to apply restrictions to.
213 * @param {Object} config
214 * @param {number} [config.maxEntries] The maximum number of entries to cache.
215 * Entries used the least will be removed as the maximum is reached.
216 * @param {number} [config.maxAgeSeconds] The maximum age of an entry before
217 * it's treated as stale and removed.
218 */
219 constructor(cacheName, config = {}) {
220 {
221 assert_mjs.assert.isType(cacheName, 'string', {
222 moduleName: 'workbox-expiration',
223 className: 'CacheExpiration',
224 funcName: 'constructor',
225 paramName: 'cacheName'
226 });
227
228 if (!(config.maxEntries || config.maxAgeSeconds)) {
229 throw new WorkboxError_mjs.WorkboxError('max-entries-or-age-required', {
230 moduleName: 'workbox-expiration',
231 className: 'CacheExpiration',
232 funcName: 'constructor'
233 });
234 }
235
236 if (config.maxEntries) {
237 assert_mjs.assert.isType(config.maxEntries, 'number', {
238 moduleName: 'workbox-expiration',
239 className: 'CacheExpiration',
240 funcName: 'constructor',
241 paramName: 'config.maxEntries'
242 }); // TODO: Assert is positive
243 }
244
245 if (config.maxAgeSeconds) {
246 assert_mjs.assert.isType(config.maxAgeSeconds, 'number', {
247 moduleName: 'workbox-expiration',
248 className: 'CacheExpiration',
249 funcName: 'constructor',
250 paramName: 'config.maxAgeSeconds'
251 }); // TODO: Assert is positive
252 }
253 }
254
255 this._isRunning = false;
256 this._rerunRequested = false;
257 this._maxEntries = config.maxEntries;
258 this._maxAgeSeconds = config.maxAgeSeconds;
259 this._cacheName = cacheName;
260 this._timestampModel = new CacheTimestampsModel(cacheName);
261 }
262 /**
263 * Expires entries for the given cache and given criteria.
264 */
265
266
267 async expireEntries() {
268 if (this._isRunning) {
269 this._rerunRequested = true;
270 return;
271 }
272
273 this._isRunning = true;
274 const minTimestamp = this._maxAgeSeconds ? Date.now() - this._maxAgeSeconds * 1000 : undefined;
275 const urlsExpired = await this._timestampModel.expireEntries(minTimestamp, this._maxEntries); // Delete URLs from the cache
276
277 const cache = await caches.open(this._cacheName);
278
279 for (const url of urlsExpired) {
280 await cache.delete(url);
281 }
282
283 {
284 if (urlsExpired.length > 0) {
285 logger_mjs.logger.groupCollapsed(`Expired ${urlsExpired.length} ` + `${urlsExpired.length === 1 ? 'entry' : 'entries'} and removed ` + `${urlsExpired.length === 1 ? 'it' : 'them'} from the ` + `'${this._cacheName}' cache.`);
286 logger_mjs.logger.log(`Expired the following ${urlsExpired.length === 1 ? 'URL' : 'URLs'}:`);
287 urlsExpired.forEach(url => logger_mjs.logger.log(` ${url}`));
288 logger_mjs.logger.groupEnd();
289 } else {
290 logger_mjs.logger.debug(`Cache expiration ran and found no entries to remove.`);
291 }
292 }
293
294 this._isRunning = false;
295
296 if (this._rerunRequested) {
297 this._rerunRequested = false;
298 this.expireEntries();
299 }
300 }
301 /**
302 * Update the timestamp for the given URL. This ensures the when
303 * removing entries based on maximum entries, most recently used
304 * is accurate or when expiring, the timestamp is up-to-date.
305 *
306 * @param {string} url
307 */
308
309
310 async updateTimestamp(url) {
311 {
312 assert_mjs.assert.isType(url, 'string', {
313 moduleName: 'workbox-expiration',
314 className: 'CacheExpiration',
315 funcName: 'updateTimestamp',
316 paramName: 'url'
317 });
318 }
319
320 await this._timestampModel.setTimestamp(url, Date.now());
321 }
322 /**
323 * Can be used to check if a URL has expired or not before it's used.
324 *
325 * This requires a look up from IndexedDB, so can be slow.
326 *
327 * Note: This method will not remove the cached entry, call
328 * `expireEntries()` to remove indexedDB and Cache entries.
329 *
330 * @param {string} url
331 * @return {boolean}
332 */
333
334
335 async isURLExpired(url) {
336 {
337 if (!this._maxAgeSeconds) {
338 throw new WorkboxError_mjs.WorkboxError(`expired-test-without-max-age`, {
339 methodName: 'isURLExpired',
340 paramName: 'maxAgeSeconds'
341 });
342 }
343 }
344
345 const timestamp = await this._timestampModel.getTimestamp(url);
346 const expireOlderThan = Date.now() - this._maxAgeSeconds * 1000;
347 return timestamp < expireOlderThan;
348 }
349 /**
350 * Removes the IndexedDB object store used to keep track of cache expiration
351 * metadata.
352 */
353
354
355 async delete() {
356 // Make sure we don't attempt another rerun if we're called in the middle of
357 // a cache expiration.
358 this._rerunRequested = false;
359 await this._timestampModel.expireEntries(Infinity); // Expires all.
360 }
361
362 }
363
364 /*
365 Copyright 2018 Google LLC
366
367 Use of this source code is governed by an MIT-style
368 license that can be found in the LICENSE file or at
369 https://opensource.org/licenses/MIT.
370 */
371 /**
372 * This plugin can be used in the Workbox APIs to regularly enforce a
373 * limit on the age and / or the number of cached requests.
374 *
375 * Whenever a cached request is used or updated, this plugin will look
376 * at the used Cache and remove any old or extra requests.
377 *
378 * When using `maxAgeSeconds`, requests may be used *once* after expiring
379 * because the expiration clean up will not have occurred until *after* the
380 * cached request has been used. If the request has a "Date" header, then
381 * a light weight expiration check is performed and the request will not be
382 * used immediately.
383 *
384 * When using `maxEntries`, the entry least-recently requested will be removed from the cache first.
385 *
386 * @memberof workbox.expiration
387 */
388
389 class Plugin {
390 /**
391 * @param {Object} config
392 * @param {number} [config.maxEntries] The maximum number of entries to cache.
393 * Entries used the least will be removed as the maximum is reached.
394 * @param {number} [config.maxAgeSeconds] The maximum age of an entry before
395 * it's treated as stale and removed.
396 * @param {boolean} [config.purgeOnQuotaError] Whether to opt this cache in to
397 * automatic deletion if the available storage quota has been exceeded.
398 */
399 constructor(config = {}) {
400 {
401 if (!(config.maxEntries || config.maxAgeSeconds)) {
402 throw new WorkboxError_mjs.WorkboxError('max-entries-or-age-required', {
403 moduleName: 'workbox-expiration',
404 className: 'Plugin',
405 funcName: 'constructor'
406 });
407 }
408
409 if (config.maxEntries) {
410 assert_mjs.assert.isType(config.maxEntries, 'number', {
411 moduleName: 'workbox-expiration',
412 className: 'Plugin',
413 funcName: 'constructor',
414 paramName: 'config.maxEntries'
415 });
416 }
417
418 if (config.maxAgeSeconds) {
419 assert_mjs.assert.isType(config.maxAgeSeconds, 'number', {
420 moduleName: 'workbox-expiration',
421 className: 'Plugin',
422 funcName: 'constructor',
423 paramName: 'config.maxAgeSeconds'
424 });
425 }
426 }
427
428 this._config = config;
429 this._maxAgeSeconds = config.maxAgeSeconds;
430 this._cacheExpirations = new Map();
431
432 if (config.purgeOnQuotaError) {
433 registerQuotaErrorCallback_mjs.registerQuotaErrorCallback(() => this.deleteCacheAndMetadata());
434 }
435 }
436 /**
437 * A simple helper method to return a CacheExpiration instance for a given
438 * cache name.
439 *
440 * @param {string} cacheName
441 * @return {CacheExpiration}
442 *
443 * @private
444 */
445
446
447 _getCacheExpiration(cacheName) {
448 if (cacheName === cacheNames_mjs.cacheNames.getRuntimeName()) {
449 throw new WorkboxError_mjs.WorkboxError('expire-custom-caches-only');
450 }
451
452 let cacheExpiration = this._cacheExpirations.get(cacheName);
453
454 if (!cacheExpiration) {
455 cacheExpiration = new CacheExpiration(cacheName, this._config);
456
457 this._cacheExpirations.set(cacheName, cacheExpiration);
458 }
459
460 return cacheExpiration;
461 }
462 /**
463 * A "lifecycle" callback that will be triggered automatically by the
464 * `workbox.strategies` handlers when a `Response` is about to be returned
465 * from a [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache) to
466 * the handler. It allows the `Response` to be inspected for freshness and
467 * prevents it from being used if the `Response`'s `Date` header value is
468 * older than the configured `maxAgeSeconds`.
469 *
470 * @param {Object} options
471 * @param {string} options.cacheName Name of the cache the response is in.
472 * @param {Response} options.cachedResponse The `Response` object that's been
473 * read from a cache and whose freshness should be checked.
474 * @return {Response} Either the `cachedResponse`, if it's
475 * fresh, or `null` if the `Response` is older than `maxAgeSeconds`.
476 *
477 * @private
478 */
479
480
481 cachedResponseWillBeUsed({
482 event,
483 request,
484 cacheName,
485 cachedResponse
486 }) {
487 if (!cachedResponse) {
488 return null;
489 }
490
491 let isFresh = this._isResponseDateFresh(cachedResponse); // Expire entries to ensure that even if the expiration date has
492 // expired, it'll only be used once.
493
494
495 const cacheExpiration = this._getCacheExpiration(cacheName);
496
497 cacheExpiration.expireEntries(); // Update the metadata for the request URL to the current timestamp,
498 // but don't `await` it as we don't want to block the response.
499
500 const updateTimestampDone = cacheExpiration.updateTimestamp(request.url);
501
502 if (event) {
503 try {
504 event.waitUntil(updateTimestampDone);
505 } catch (error) {
506 {
507 logger_mjs.logger.warn(`Unable to ensure service worker stays alive when ` + `updating cache entry for '${getFriendlyURL_mjs.getFriendlyURL(event.request.url)}'.`);
508 }
509 }
510 }
511
512 return isFresh ? cachedResponse : null;
513 }
514 /**
515 * @param {Response} cachedResponse
516 * @return {boolean}
517 *
518 * @private
519 */
520
521
522 _isResponseDateFresh(cachedResponse) {
523 if (!this._maxAgeSeconds) {
524 // We aren't expiring by age, so return true, it's fresh
525 return true;
526 } // Check if the 'date' header will suffice a quick expiration check.
527 // See https://github.com/GoogleChromeLabs/sw-toolbox/issues/164 for
528 // discussion.
529
530
531 const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse);
532
533 if (dateHeaderTimestamp === null) {
534 // Unable to parse date, so assume it's fresh.
535 return true;
536 } // If we have a valid headerTime, then our response is fresh iff the
537 // headerTime plus maxAgeSeconds is greater than the current time.
538
539
540 const now = Date.now();
541 return dateHeaderTimestamp >= now - this._maxAgeSeconds * 1000;
542 }
543 /**
544 * This method will extract the data header and parse it into a useful
545 * value.
546 *
547 * @param {Response} cachedResponse
548 * @return {number}
549 *
550 * @private
551 */
552
553
554 _getDateHeaderTimestamp(cachedResponse) {
555 if (!cachedResponse.headers.has('date')) {
556 return null;
557 }
558
559 const dateHeader = cachedResponse.headers.get('date');
560 const parsedDate = new Date(dateHeader);
561 const headerTime = parsedDate.getTime(); // If the Date header was invalid for some reason, parsedDate.getTime()
562 // will return NaN.
563
564 if (isNaN(headerTime)) {
565 return null;
566 }
567
568 return headerTime;
569 }
570 /**
571 * A "lifecycle" callback that will be triggered automatically by the
572 * `workbox.strategies` handlers when an entry is added to a cache.
573 *
574 * @param {Object} options
575 * @param {string} options.cacheName Name of the cache that was updated.
576 * @param {string} options.request The Request for the cached entry.
577 *
578 * @private
579 */
580
581
582 async cacheDidUpdate({
583 cacheName,
584 request
585 }) {
586 {
587 assert_mjs.assert.isType(cacheName, 'string', {
588 moduleName: 'workbox-expiration',
589 className: 'Plugin',
590 funcName: 'cacheDidUpdate',
591 paramName: 'cacheName'
592 });
593 assert_mjs.assert.isInstance(request, Request, {
594 moduleName: 'workbox-expiration',
595 className: 'Plugin',
596 funcName: 'cacheDidUpdate',
597 paramName: 'request'
598 });
599 }
600
601 const cacheExpiration = this._getCacheExpiration(cacheName);
602
603 await cacheExpiration.updateTimestamp(request.url);
604 await cacheExpiration.expireEntries();
605 }
606 /**
607 * This is a helper method that performs two operations:
608 *
609 * - Deletes *all* the underlying Cache instances associated with this plugin
610 * instance, by calling caches.delete() on your behalf.
611 * - Deletes the metadata from IndexedDB used to keep track of expiration
612 * details for each Cache instance.
613 *
614 * When using cache expiration, calling this method is preferable to calling
615 * `caches.delete()` directly, since this will ensure that the IndexedDB
616 * metadata is also cleanly removed and open IndexedDB instances are deleted.
617 *
618 * Note that if you're *not* using cache expiration for a given cache, calling
619 * `caches.delete()` and passing in the cache's name should be sufficient.
620 * There is no Workbox-specific method needed for cleanup in that case.
621 */
622
623
624 async deleteCacheAndMetadata() {
625 // Do this one at a time instead of all at once via `Promise.all()` to
626 // reduce the chance of inconsistency if a promise rejects.
627 for (const [cacheName, cacheExpiration] of this._cacheExpirations) {
628 await caches.delete(cacheName);
629 await cacheExpiration.delete();
630 } // Reset this._cacheExpirations to its initial state.
631
632
633 this._cacheExpirations = new Map();
634 }
635
636 }
637
638 /*
639 Copyright 2018 Google LLC
640
641 Use of this source code is governed by an MIT-style
642 license that can be found in the LICENSE file or at
643 https://opensource.org/licenses/MIT.
644 */
645
646 exports.CacheExpiration = CacheExpiration;
647 exports.Plugin = Plugin;
648
649 return exports;
650
651}({}, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core));
652