| 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 | import { assert } from 'workbox-core/_private/assert.js';
|
|---|
| 9 | import { cacheNames } from 'workbox-core/_private/cacheNames.js';
|
|---|
| 10 | import { dontWaitFor } from 'workbox-core/_private/dontWaitFor.js';
|
|---|
| 11 | import { getFriendlyURL } from 'workbox-core/_private/getFriendlyURL.js';
|
|---|
| 12 | import { logger } from 'workbox-core/_private/logger.js';
|
|---|
| 13 | import { registerQuotaErrorCallback } from 'workbox-core/registerQuotaErrorCallback.js';
|
|---|
| 14 | import { WorkboxError } from 'workbox-core/_private/WorkboxError.js';
|
|---|
| 15 | import { CacheExpiration } from './CacheExpiration.js';
|
|---|
| 16 | import './_version.js';
|
|---|
| 17 | /**
|
|---|
| 18 | * This plugin can be used in a `workbox-strategy` to regularly enforce a
|
|---|
| 19 | * limit on the age and / or the number of cached requests.
|
|---|
| 20 | *
|
|---|
| 21 | * It can only be used with `workbox-strategy` instances that have a
|
|---|
| 22 | * [custom `cacheName` property set](/web/tools/workbox/guides/configure-workbox#custom_cache_names_in_strategies).
|
|---|
| 23 | * In other words, it can't be used to expire entries in strategy that uses the
|
|---|
| 24 | * default runtime cache name.
|
|---|
| 25 | *
|
|---|
| 26 | * Whenever a cached response is used or updated, this plugin will look
|
|---|
| 27 | * at the associated cache and remove any old or extra responses.
|
|---|
| 28 | *
|
|---|
| 29 | * When using `maxAgeSeconds`, responses may be used *once* after expiring
|
|---|
| 30 | * because the expiration clean up will not have occurred until *after* the
|
|---|
| 31 | * cached response has been used. If the response has a "Date" header, then
|
|---|
| 32 | * a light weight expiration check is performed and the response will not be
|
|---|
| 33 | * used immediately.
|
|---|
| 34 | *
|
|---|
| 35 | * When using `maxEntries`, the entry least-recently requested will be removed
|
|---|
| 36 | * from the cache first.
|
|---|
| 37 | *
|
|---|
| 38 | * @memberof workbox-expiration
|
|---|
| 39 | */
|
|---|
| 40 | class ExpirationPlugin {
|
|---|
| 41 | /**
|
|---|
| 42 | * @param {ExpirationPluginOptions} config
|
|---|
| 43 | * @param {number} [config.maxEntries] The maximum number of entries to cache.
|
|---|
| 44 | * Entries used the least will be removed as the maximum is reached.
|
|---|
| 45 | * @param {number} [config.maxAgeSeconds] The maximum age of an entry before
|
|---|
| 46 | * it's treated as stale and removed.
|
|---|
| 47 | * @param {Object} [config.matchOptions] The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters)
|
|---|
| 48 | * that will be used when calling `delete()` on the cache.
|
|---|
| 49 | * @param {boolean} [config.purgeOnQuotaError] Whether to opt this cache in to
|
|---|
| 50 | * automatic deletion if the available storage quota has been exceeded.
|
|---|
| 51 | */
|
|---|
| 52 | constructor(config = {}) {
|
|---|
| 53 | /**
|
|---|
| 54 | * A "lifecycle" callback that will be triggered automatically by the
|
|---|
| 55 | * `workbox-strategies` handlers when a `Response` is about to be returned
|
|---|
| 56 | * from a [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache) to
|
|---|
| 57 | * the handler. It allows the `Response` to be inspected for freshness and
|
|---|
| 58 | * prevents it from being used if the `Response`'s `Date` header value is
|
|---|
| 59 | * older than the configured `maxAgeSeconds`.
|
|---|
| 60 | *
|
|---|
| 61 | * @param {Object} options
|
|---|
| 62 | * @param {string} options.cacheName Name of the cache the response is in.
|
|---|
| 63 | * @param {Response} options.cachedResponse The `Response` object that's been
|
|---|
| 64 | * read from a cache and whose freshness should be checked.
|
|---|
| 65 | * @return {Response} Either the `cachedResponse`, if it's
|
|---|
| 66 | * fresh, or `null` if the `Response` is older than `maxAgeSeconds`.
|
|---|
| 67 | *
|
|---|
| 68 | * @private
|
|---|
| 69 | */
|
|---|
| 70 | this.cachedResponseWillBeUsed = async ({ event, request, cacheName, cachedResponse, }) => {
|
|---|
| 71 | if (!cachedResponse) {
|
|---|
| 72 | return null;
|
|---|
| 73 | }
|
|---|
| 74 | const isFresh = this._isResponseDateFresh(cachedResponse);
|
|---|
| 75 | // Expire entries to ensure that even if the expiration date has
|
|---|
| 76 | // expired, it'll only be used once.
|
|---|
| 77 | const cacheExpiration = this._getCacheExpiration(cacheName);
|
|---|
| 78 | dontWaitFor(cacheExpiration.expireEntries());
|
|---|
| 79 | // Update the metadata for the request URL to the current timestamp,
|
|---|
| 80 | // but don't `await` it as we don't want to block the response.
|
|---|
| 81 | const updateTimestampDone = cacheExpiration.updateTimestamp(request.url);
|
|---|
| 82 | if (event) {
|
|---|
| 83 | try {
|
|---|
| 84 | event.waitUntil(updateTimestampDone);
|
|---|
| 85 | }
|
|---|
| 86 | catch (error) {
|
|---|
| 87 | if (process.env.NODE_ENV !== 'production') {
|
|---|
| 88 | // The event may not be a fetch event; only log the URL if it is.
|
|---|
| 89 | if ('request' in event) {
|
|---|
| 90 | logger.warn(`Unable to ensure service worker stays alive when ` +
|
|---|
| 91 | `updating cache entry for ` +
|
|---|
| 92 | `'${getFriendlyURL(event.request.url)}'.`);
|
|---|
| 93 | }
|
|---|
| 94 | }
|
|---|
| 95 | }
|
|---|
| 96 | }
|
|---|
| 97 | return isFresh ? cachedResponse : null;
|
|---|
| 98 | };
|
|---|
| 99 | /**
|
|---|
| 100 | * A "lifecycle" callback that will be triggered automatically by the
|
|---|
| 101 | * `workbox-strategies` handlers when an entry is added to a cache.
|
|---|
| 102 | *
|
|---|
| 103 | * @param {Object} options
|
|---|
| 104 | * @param {string} options.cacheName Name of the cache that was updated.
|
|---|
| 105 | * @param {string} options.request The Request for the cached entry.
|
|---|
| 106 | *
|
|---|
| 107 | * @private
|
|---|
| 108 | */
|
|---|
| 109 | this.cacheDidUpdate = async ({ cacheName, request, }) => {
|
|---|
| 110 | if (process.env.NODE_ENV !== 'production') {
|
|---|
| 111 | assert.isType(cacheName, 'string', {
|
|---|
| 112 | moduleName: 'workbox-expiration',
|
|---|
| 113 | className: 'Plugin',
|
|---|
| 114 | funcName: 'cacheDidUpdate',
|
|---|
| 115 | paramName: 'cacheName',
|
|---|
| 116 | });
|
|---|
| 117 | assert.isInstance(request, Request, {
|
|---|
| 118 | moduleName: 'workbox-expiration',
|
|---|
| 119 | className: 'Plugin',
|
|---|
| 120 | funcName: 'cacheDidUpdate',
|
|---|
| 121 | paramName: 'request',
|
|---|
| 122 | });
|
|---|
| 123 | }
|
|---|
| 124 | const cacheExpiration = this._getCacheExpiration(cacheName);
|
|---|
| 125 | await cacheExpiration.updateTimestamp(request.url);
|
|---|
| 126 | await cacheExpiration.expireEntries();
|
|---|
| 127 | };
|
|---|
| 128 | if (process.env.NODE_ENV !== 'production') {
|
|---|
| 129 | if (!(config.maxEntries || config.maxAgeSeconds)) {
|
|---|
| 130 | throw new WorkboxError('max-entries-or-age-required', {
|
|---|
| 131 | moduleName: 'workbox-expiration',
|
|---|
| 132 | className: 'Plugin',
|
|---|
| 133 | funcName: 'constructor',
|
|---|
| 134 | });
|
|---|
| 135 | }
|
|---|
| 136 | if (config.maxEntries) {
|
|---|
| 137 | assert.isType(config.maxEntries, 'number', {
|
|---|
| 138 | moduleName: 'workbox-expiration',
|
|---|
| 139 | className: 'Plugin',
|
|---|
| 140 | funcName: 'constructor',
|
|---|
| 141 | paramName: 'config.maxEntries',
|
|---|
| 142 | });
|
|---|
| 143 | }
|
|---|
| 144 | if (config.maxAgeSeconds) {
|
|---|
| 145 | assert.isType(config.maxAgeSeconds, 'number', {
|
|---|
| 146 | moduleName: 'workbox-expiration',
|
|---|
| 147 | className: 'Plugin',
|
|---|
| 148 | funcName: 'constructor',
|
|---|
| 149 | paramName: 'config.maxAgeSeconds',
|
|---|
| 150 | });
|
|---|
| 151 | }
|
|---|
| 152 | }
|
|---|
| 153 | this._config = config;
|
|---|
| 154 | this._maxAgeSeconds = config.maxAgeSeconds;
|
|---|
| 155 | this._cacheExpirations = new Map();
|
|---|
| 156 | if (config.purgeOnQuotaError) {
|
|---|
| 157 | registerQuotaErrorCallback(() => this.deleteCacheAndMetadata());
|
|---|
| 158 | }
|
|---|
| 159 | }
|
|---|
| 160 | /**
|
|---|
| 161 | * A simple helper method to return a CacheExpiration instance for a given
|
|---|
| 162 | * cache name.
|
|---|
| 163 | *
|
|---|
| 164 | * @param {string} cacheName
|
|---|
| 165 | * @return {CacheExpiration}
|
|---|
| 166 | *
|
|---|
| 167 | * @private
|
|---|
| 168 | */
|
|---|
| 169 | _getCacheExpiration(cacheName) {
|
|---|
| 170 | if (cacheName === cacheNames.getRuntimeName()) {
|
|---|
| 171 | throw new WorkboxError('expire-custom-caches-only');
|
|---|
| 172 | }
|
|---|
| 173 | let cacheExpiration = this._cacheExpirations.get(cacheName);
|
|---|
| 174 | if (!cacheExpiration) {
|
|---|
| 175 | cacheExpiration = new CacheExpiration(cacheName, this._config);
|
|---|
| 176 | this._cacheExpirations.set(cacheName, cacheExpiration);
|
|---|
| 177 | }
|
|---|
| 178 | return cacheExpiration;
|
|---|
| 179 | }
|
|---|
| 180 | /**
|
|---|
| 181 | * @param {Response} cachedResponse
|
|---|
| 182 | * @return {boolean}
|
|---|
| 183 | *
|
|---|
| 184 | * @private
|
|---|
| 185 | */
|
|---|
| 186 | _isResponseDateFresh(cachedResponse) {
|
|---|
| 187 | if (!this._maxAgeSeconds) {
|
|---|
| 188 | // We aren't expiring by age, so return true, it's fresh
|
|---|
| 189 | return true;
|
|---|
| 190 | }
|
|---|
| 191 | // Check if the 'date' header will suffice a quick expiration check.
|
|---|
| 192 | // See https://github.com/GoogleChromeLabs/sw-toolbox/issues/164 for
|
|---|
| 193 | // discussion.
|
|---|
| 194 | const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse);
|
|---|
| 195 | if (dateHeaderTimestamp === null) {
|
|---|
| 196 | // Unable to parse date, so assume it's fresh.
|
|---|
| 197 | return true;
|
|---|
| 198 | }
|
|---|
| 199 | // If we have a valid headerTime, then our response is fresh iff the
|
|---|
| 200 | // headerTime plus maxAgeSeconds is greater than the current time.
|
|---|
| 201 | const now = Date.now();
|
|---|
| 202 | return dateHeaderTimestamp >= now - this._maxAgeSeconds * 1000;
|
|---|
| 203 | }
|
|---|
| 204 | /**
|
|---|
| 205 | * This method will extract the data header and parse it into a useful
|
|---|
| 206 | * value.
|
|---|
| 207 | *
|
|---|
| 208 | * @param {Response} cachedResponse
|
|---|
| 209 | * @return {number|null}
|
|---|
| 210 | *
|
|---|
| 211 | * @private
|
|---|
| 212 | */
|
|---|
| 213 | _getDateHeaderTimestamp(cachedResponse) {
|
|---|
| 214 | if (!cachedResponse.headers.has('date')) {
|
|---|
| 215 | return null;
|
|---|
| 216 | }
|
|---|
| 217 | const dateHeader = cachedResponse.headers.get('date');
|
|---|
| 218 | const parsedDate = new Date(dateHeader);
|
|---|
| 219 | const headerTime = parsedDate.getTime();
|
|---|
| 220 | // If the Date header was invalid for some reason, parsedDate.getTime()
|
|---|
| 221 | // will return NaN.
|
|---|
| 222 | if (isNaN(headerTime)) {
|
|---|
| 223 | return null;
|
|---|
| 224 | }
|
|---|
| 225 | return headerTime;
|
|---|
| 226 | }
|
|---|
| 227 | /**
|
|---|
| 228 | * This is a helper method that performs two operations:
|
|---|
| 229 | *
|
|---|
| 230 | * - Deletes *all* the underlying Cache instances associated with this plugin
|
|---|
| 231 | * instance, by calling caches.delete() on your behalf.
|
|---|
| 232 | * - Deletes the metadata from IndexedDB used to keep track of expiration
|
|---|
| 233 | * details for each Cache instance.
|
|---|
| 234 | *
|
|---|
| 235 | * When using cache expiration, calling this method is preferable to calling
|
|---|
| 236 | * `caches.delete()` directly, since this will ensure that the IndexedDB
|
|---|
| 237 | * metadata is also cleanly removed and open IndexedDB instances are deleted.
|
|---|
| 238 | *
|
|---|
| 239 | * Note that if you're *not* using cache expiration for a given cache, calling
|
|---|
| 240 | * `caches.delete()` and passing in the cache's name should be sufficient.
|
|---|
| 241 | * There is no Workbox-specific method needed for cleanup in that case.
|
|---|
| 242 | */
|
|---|
| 243 | async deleteCacheAndMetadata() {
|
|---|
| 244 | // Do this one at a time instead of all at once via `Promise.all()` to
|
|---|
| 245 | // reduce the chance of inconsistency if a promise rejects.
|
|---|
| 246 | for (const [cacheName, cacheExpiration] of this._cacheExpirations) {
|
|---|
| 247 | await self.caches.delete(cacheName);
|
|---|
| 248 | await cacheExpiration.delete();
|
|---|
| 249 | }
|
|---|
| 250 | // Reset this._cacheExpirations to its initial state.
|
|---|
| 251 | this._cacheExpirations = new Map();
|
|---|
| 252 | }
|
|---|
| 253 | }
|
|---|
| 254 | export { ExpirationPlugin };
|
|---|