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