| 1 | /*
|
|---|
| 2 | Copyright 2018 Google LLC
|
|---|
| 3 |
|
|---|
| 4 | Use of this source code is governed by an MIT-style
|
|---|
| 5 | license that can be found in the LICENSE file or at
|
|---|
| 6 | https://opensource.org/licenses/MIT.
|
|---|
| 7 | */
|
|---|
| 8 |
|
|---|
| 9 | import {assert} from 'workbox-core/_private/assert.js';
|
|---|
| 10 | import {dontWaitFor} from 'workbox-core/_private/dontWaitFor.js';
|
|---|
| 11 | import {logger} from 'workbox-core/_private/logger.js';
|
|---|
| 12 | import {WorkboxError} from 'workbox-core/_private/WorkboxError.js';
|
|---|
| 13 |
|
|---|
| 14 | import {CacheTimestampsModel} from './models/CacheTimestampsModel.js';
|
|---|
| 15 |
|
|---|
| 16 | import './_version.js';
|
|---|
| 17 |
|
|---|
| 18 | interface CacheExpirationConfig {
|
|---|
| 19 | maxEntries?: number;
|
|---|
| 20 | maxAgeSeconds?: number;
|
|---|
| 21 | matchOptions?: CacheQueryOptions;
|
|---|
| 22 | }
|
|---|
| 23 |
|
|---|
| 24 | /**
|
|---|
| 25 | * The `CacheExpiration` class allows you define an expiration and / or
|
|---|
| 26 | * limit on the number of responses stored in a
|
|---|
| 27 | * [`Cache`](https://developer.mozilla.org/en-US/docs/Web/API/Cache).
|
|---|
| 28 | *
|
|---|
| 29 | * @memberof workbox-expiration
|
|---|
| 30 | */
|
|---|
| 31 | class CacheExpiration {
|
|---|
| 32 | private _isRunning = false;
|
|---|
| 33 | private _rerunRequested = false;
|
|---|
| 34 | private readonly _maxEntries?: number;
|
|---|
| 35 | private readonly _maxAgeSeconds?: number;
|
|---|
| 36 | private readonly _matchOptions?: CacheQueryOptions;
|
|---|
| 37 | private readonly _cacheName: string;
|
|---|
| 38 | private readonly _timestampModel: CacheTimestampsModel;
|
|---|
| 39 |
|
|---|
| 40 | /**
|
|---|
| 41 | * To construct a new CacheExpiration instance you must provide at least
|
|---|
| 42 | * one of the `config` properties.
|
|---|
| 43 | *
|
|---|
| 44 | * @param {string} cacheName Name of the cache to apply restrictions to.
|
|---|
| 45 | * @param {Object} config
|
|---|
| 46 | * @param {number} [config.maxEntries] The maximum number of entries to cache.
|
|---|
| 47 | * Entries used the least will be removed as the maximum is reached.
|
|---|
| 48 | * @param {number} [config.maxAgeSeconds] The maximum age of an entry before
|
|---|
| 49 | * it's treated as stale and removed.
|
|---|
| 50 | * @param {Object} [config.matchOptions] The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters)
|
|---|
| 51 | * that will be used when calling `delete()` on the cache.
|
|---|
| 52 | */
|
|---|
| 53 | constructor(cacheName: string, config: CacheExpirationConfig = {}) {
|
|---|
| 54 | if (process.env.NODE_ENV !== 'production') {
|
|---|
| 55 | assert!.isType(cacheName, 'string', {
|
|---|
| 56 | moduleName: 'workbox-expiration',
|
|---|
| 57 | className: 'CacheExpiration',
|
|---|
| 58 | funcName: 'constructor',
|
|---|
| 59 | paramName: 'cacheName',
|
|---|
| 60 | });
|
|---|
| 61 |
|
|---|
| 62 | if (!(config.maxEntries || config.maxAgeSeconds)) {
|
|---|
| 63 | throw new WorkboxError('max-entries-or-age-required', {
|
|---|
| 64 | moduleName: 'workbox-expiration',
|
|---|
| 65 | className: 'CacheExpiration',
|
|---|
| 66 | funcName: 'constructor',
|
|---|
| 67 | });
|
|---|
| 68 | }
|
|---|
| 69 |
|
|---|
| 70 | if (config.maxEntries) {
|
|---|
| 71 | assert!.isType(config.maxEntries, 'number', {
|
|---|
| 72 | moduleName: 'workbox-expiration',
|
|---|
| 73 | className: 'CacheExpiration',
|
|---|
| 74 | funcName: 'constructor',
|
|---|
| 75 | paramName: 'config.maxEntries',
|
|---|
| 76 | });
|
|---|
| 77 | }
|
|---|
| 78 |
|
|---|
| 79 | if (config.maxAgeSeconds) {
|
|---|
| 80 | assert!.isType(config.maxAgeSeconds, 'number', {
|
|---|
| 81 | moduleName: 'workbox-expiration',
|
|---|
| 82 | className: 'CacheExpiration',
|
|---|
| 83 | funcName: 'constructor',
|
|---|
| 84 | paramName: 'config.maxAgeSeconds',
|
|---|
| 85 | });
|
|---|
| 86 | }
|
|---|
| 87 | }
|
|---|
| 88 |
|
|---|
| 89 | this._maxEntries = config.maxEntries;
|
|---|
| 90 | this._maxAgeSeconds = config.maxAgeSeconds;
|
|---|
| 91 | this._matchOptions = config.matchOptions;
|
|---|
| 92 | this._cacheName = cacheName;
|
|---|
| 93 | this._timestampModel = new CacheTimestampsModel(cacheName);
|
|---|
| 94 | }
|
|---|
| 95 |
|
|---|
| 96 | /**
|
|---|
| 97 | * Expires entries for the given cache and given criteria.
|
|---|
| 98 | */
|
|---|
| 99 | async expireEntries(): Promise<void> {
|
|---|
| 100 | if (this._isRunning) {
|
|---|
| 101 | this._rerunRequested = true;
|
|---|
| 102 | return;
|
|---|
| 103 | }
|
|---|
| 104 | this._isRunning = true;
|
|---|
| 105 |
|
|---|
| 106 | const minTimestamp = this._maxAgeSeconds
|
|---|
| 107 | ? Date.now() - this._maxAgeSeconds * 1000
|
|---|
| 108 | : 0;
|
|---|
| 109 |
|
|---|
| 110 | const urlsExpired = await this._timestampModel.expireEntries(
|
|---|
| 111 | minTimestamp,
|
|---|
| 112 | this._maxEntries,
|
|---|
| 113 | );
|
|---|
| 114 |
|
|---|
| 115 | // Delete URLs from the cache
|
|---|
| 116 | const cache = await self.caches.open(this._cacheName);
|
|---|
| 117 | for (const url of urlsExpired) {
|
|---|
| 118 | await cache.delete(url, this._matchOptions);
|
|---|
| 119 | }
|
|---|
| 120 |
|
|---|
| 121 | if (process.env.NODE_ENV !== 'production') {
|
|---|
| 122 | if (urlsExpired.length > 0) {
|
|---|
| 123 | logger.groupCollapsed(
|
|---|
| 124 | `Expired ${urlsExpired.length} ` +
|
|---|
| 125 | `${urlsExpired.length === 1 ? 'entry' : 'entries'} and removed ` +
|
|---|
| 126 | `${urlsExpired.length === 1 ? 'it' : 'them'} from the ` +
|
|---|
| 127 | `'${this._cacheName}' cache.`,
|
|---|
| 128 | );
|
|---|
| 129 | logger.log(
|
|---|
| 130 | `Expired the following ${urlsExpired.length === 1 ? 'URL' : 'URLs'}:`,
|
|---|
| 131 | );
|
|---|
| 132 | urlsExpired.forEach((url) => logger.log(` ${url}`));
|
|---|
| 133 | logger.groupEnd();
|
|---|
| 134 | } else {
|
|---|
| 135 | logger.debug(`Cache expiration ran and found no entries to remove.`);
|
|---|
| 136 | }
|
|---|
| 137 | }
|
|---|
| 138 |
|
|---|
| 139 | this._isRunning = false;
|
|---|
| 140 | if (this._rerunRequested) {
|
|---|
| 141 | this._rerunRequested = false;
|
|---|
| 142 | dontWaitFor(this.expireEntries());
|
|---|
| 143 | }
|
|---|
| 144 | }
|
|---|
| 145 |
|
|---|
| 146 | /**
|
|---|
| 147 | * Update the timestamp for the given URL. This ensures the when
|
|---|
| 148 | * removing entries based on maximum entries, most recently used
|
|---|
| 149 | * is accurate or when expiring, the timestamp is up-to-date.
|
|---|
| 150 | *
|
|---|
| 151 | * @param {string} url
|
|---|
| 152 | */
|
|---|
| 153 | async updateTimestamp(url: string): Promise<void> {
|
|---|
| 154 | if (process.env.NODE_ENV !== 'production') {
|
|---|
| 155 | assert!.isType(url, 'string', {
|
|---|
| 156 | moduleName: 'workbox-expiration',
|
|---|
| 157 | className: 'CacheExpiration',
|
|---|
| 158 | funcName: 'updateTimestamp',
|
|---|
| 159 | paramName: 'url',
|
|---|
| 160 | });
|
|---|
| 161 | }
|
|---|
| 162 |
|
|---|
| 163 | await this._timestampModel.setTimestamp(url, Date.now());
|
|---|
| 164 | }
|
|---|
| 165 |
|
|---|
| 166 | /**
|
|---|
| 167 | * Can be used to check if a URL has expired or not before it's used.
|
|---|
| 168 | *
|
|---|
| 169 | * This requires a look up from IndexedDB, so can be slow.
|
|---|
| 170 | *
|
|---|
| 171 | * Note: This method will not remove the cached entry, call
|
|---|
| 172 | * `expireEntries()` to remove indexedDB and Cache entries.
|
|---|
| 173 | *
|
|---|
| 174 | * @param {string} url
|
|---|
| 175 | * @return {boolean}
|
|---|
| 176 | */
|
|---|
| 177 | async isURLExpired(url: string): Promise<boolean> {
|
|---|
| 178 | if (!this._maxAgeSeconds) {
|
|---|
| 179 | if (process.env.NODE_ENV !== 'production') {
|
|---|
| 180 | throw new WorkboxError(`expired-test-without-max-age`, {
|
|---|
| 181 | methodName: 'isURLExpired',
|
|---|
| 182 | paramName: 'maxAgeSeconds',
|
|---|
| 183 | });
|
|---|
| 184 | }
|
|---|
| 185 | return false;
|
|---|
| 186 | } else {
|
|---|
| 187 | const timestamp = await this._timestampModel.getTimestamp(url);
|
|---|
| 188 | const expireOlderThan = Date.now() - this._maxAgeSeconds * 1000;
|
|---|
| 189 | return timestamp !== undefined ? timestamp < expireOlderThan : true;
|
|---|
| 190 | }
|
|---|
| 191 | }
|
|---|
| 192 |
|
|---|
| 193 | /**
|
|---|
| 194 | * Removes the IndexedDB object store used to keep track of cache expiration
|
|---|
| 195 | * metadata.
|
|---|
| 196 | */
|
|---|
| 197 | async delete(): Promise<void> {
|
|---|
| 198 | // Make sure we don't attempt another rerun if we're called in the middle of
|
|---|
| 199 | // a cache expiration.
|
|---|
| 200 | this._rerunRequested = false;
|
|---|
| 201 | await this._timestampModel.expireEntries(Infinity); // Expires all.
|
|---|
| 202 | }
|
|---|
| 203 | }
|
|---|
| 204 |
|
|---|
| 205 | export {CacheExpiration};
|
|---|