You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

258 lines
8.7 KiB

  1. /*
  2. Copyright 2016 Google Inc. All Rights Reserved.
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. import {CacheExpiration} from './CacheExpiration.mjs';
  14. import {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';
  15. import {assert} from 'workbox-core/_private/assert.mjs';
  16. import {cacheNames} from 'workbox-core/_private/cacheNames.mjs';
  17. import {registerQuotaErrorCallback} from 'workbox-core/index.mjs';
  18. import './_version.mjs';
  19. /**
  20. * This plugin can be used in the Workbox APIs to regularly enforce a
  21. * limit on the age and / or the number of cached requests.
  22. *
  23. * Whenever a cached request is used or updated, this plugin will look
  24. * at the used Cache and remove any old or extra requests.
  25. *
  26. * When using `maxAgeSeconds`, requests may be used *once* after expiring
  27. * because the expiration clean up will not have occurred until *after* the
  28. * cached request has been used. If the request has a "Date" header, then
  29. * a light weight expiration check is performed and the request will not be
  30. * used immediately.
  31. *
  32. * When using `maxEntries`, the last request to be used will be the request
  33. * that is removed from the Cache.
  34. *
  35. * @memberof workbox.expiration
  36. */
  37. class Plugin {
  38. /**
  39. * @param {Object} config
  40. * @param {number} [config.maxEntries] The maximum number of entries to cache.
  41. * Entries used the least will be removed as the maximum is reached.
  42. * @param {number} [config.maxAgeSeconds] The maximum age of an entry before
  43. * it's treated as stale and removed.
  44. * @param {boolean} [config.purgeOnQuotaError] Whether to opt this cache in to
  45. * automatic deletion if the available storage quota has been exceeded.
  46. */
  47. constructor(config = {}) {
  48. if (process.env.NODE_ENV !== 'production') {
  49. if (!(config.maxEntries || config.maxAgeSeconds)) {
  50. throw new WorkboxError('max-entries-or-age-required', {
  51. moduleName: 'workbox-cache-expiration',
  52. className: 'Plugin',
  53. funcName: 'constructor',
  54. });
  55. }
  56. if (config.maxEntries) {
  57. assert.isType(config.maxEntries, 'number', {
  58. moduleName: 'workbox-cache-expiration',
  59. className: 'Plugin',
  60. funcName: 'constructor',
  61. paramName: 'config.maxEntries',
  62. });
  63. }
  64. if (config.maxAgeSeconds) {
  65. assert.isType(config.maxAgeSeconds, 'number', {
  66. moduleName: 'workbox-cache-expiration',
  67. className: 'Plugin',
  68. funcName: 'constructor',
  69. paramName: 'config.maxAgeSeconds',
  70. });
  71. }
  72. }
  73. this._config = config;
  74. this._maxAgeSeconds = config.maxAgeSeconds;
  75. this._cacheExpirations = new Map();
  76. if (config.purgeOnQuotaError) {
  77. registerQuotaErrorCallback(() => this.deleteCacheAndMetadata());
  78. }
  79. }
  80. /**
  81. * A simple helper method to return a CacheExpiration instance for a given
  82. * cache name.
  83. *
  84. * @param {string} cacheName
  85. * @return {CacheExpiration}
  86. *
  87. * @private
  88. */
  89. _getCacheExpiration(cacheName) {
  90. if (cacheName === cacheNames.getRuntimeName()) {
  91. throw new WorkboxError('expire-custom-caches-only');
  92. }
  93. let cacheExpiration = this._cacheExpirations.get(cacheName);
  94. if (!cacheExpiration) {
  95. cacheExpiration = new CacheExpiration(cacheName, this._config);
  96. this._cacheExpirations.set(cacheName, cacheExpiration);
  97. }
  98. return cacheExpiration;
  99. }
  100. /**
  101. * A "lifecycle" callback that will be triggered automatically by the
  102. * `workbox.runtimeCaching` handlers when a `Response` is about to be returned
  103. * from a [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache) to
  104. * the handler. It allows the `Response` to be inspected for freshness and
  105. * prevents it from being used if the `Response`'s `Date` header value is
  106. * older than the configured `maxAgeSeconds`.
  107. *
  108. * @param {Object} options
  109. * @param {string} options.cacheName Name of the cache the response is in.
  110. * @param {Response} options.cachedResponse The `Response` object that's been
  111. * read from a cache and whose freshness should be checked.
  112. * @return {Response} Either the `cachedResponse`, if it's
  113. * fresh, or `null` if the `Response` is older than `maxAgeSeconds`.
  114. *
  115. * @private
  116. */
  117. cachedResponseWillBeUsed({cacheName, cachedResponse}) {
  118. if (!cachedResponse) {
  119. return null;
  120. }
  121. let isFresh = this._isResponseDateFresh(cachedResponse);
  122. // Expire entries to ensure that even if the expiration date has
  123. // expired, it'll only be used once.
  124. const cacheExpiration = this._getCacheExpiration(cacheName);
  125. cacheExpiration.expireEntries();
  126. return isFresh ? cachedResponse : null;
  127. }
  128. /**
  129. * @param {Response} cachedResponse
  130. * @return {boolean}
  131. *
  132. * @private
  133. */
  134. _isResponseDateFresh(cachedResponse) {
  135. if (!this._maxAgeSeconds) {
  136. // We aren't expiring by age, so return true, it's fresh
  137. return true;
  138. }
  139. // Check if the 'date' header will suffice a quick expiration check.
  140. // See https://github.com/GoogleChromeLabs/sw-toolbox/issues/164 for
  141. // discussion.
  142. const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse);
  143. if (dateHeaderTimestamp === null) {
  144. // Unable to parse date, so assume it's fresh.
  145. return true;
  146. }
  147. // If we have a valid headerTime, then our response is fresh iff the
  148. // headerTime plus maxAgeSeconds is greater than the current time.
  149. const now = Date.now();
  150. return dateHeaderTimestamp >= now - (this._maxAgeSeconds * 1000);
  151. }
  152. /**
  153. * This method will extract the data header and parse it into a useful
  154. * value.
  155. *
  156. * @param {Response} cachedResponse
  157. * @return {number}
  158. *
  159. * @private
  160. */
  161. _getDateHeaderTimestamp(cachedResponse) {
  162. if (!cachedResponse.headers.has('date')) {
  163. return null;
  164. }
  165. const dateHeader = cachedResponse.headers.get('date');
  166. const parsedDate = new Date(dateHeader);
  167. const headerTime = parsedDate.getTime();
  168. // If the Date header was invalid for some reason, parsedDate.getTime()
  169. // will return NaN.
  170. if (isNaN(headerTime)) {
  171. return null;
  172. }
  173. return headerTime;
  174. }
  175. /**
  176. * A "lifecycle" callback that will be triggered automatically by the
  177. * `workbox.runtimeCaching` handlers when an entry is added to a cache.
  178. *
  179. * @param {Object} options
  180. * @param {string} options.cacheName Name of the cache that was updated.
  181. * @param {string} options.request The Request for the cached entry.
  182. *
  183. * @private
  184. */
  185. async cacheDidUpdate({cacheName, request}) {
  186. if (process.env.NODE_ENV !== 'production') {
  187. assert.isType(cacheName, 'string', {
  188. moduleName: 'workbox-cache-expiration',
  189. className: 'Plugin',
  190. funcName: 'cacheDidUpdate',
  191. paramName: 'cacheName',
  192. });
  193. assert.isInstance(request, Request, {
  194. moduleName: 'workbox-cache-expiration',
  195. className: 'Plugin',
  196. funcName: 'cacheDidUpdate',
  197. paramName: 'request',
  198. });
  199. }
  200. const cacheExpiration = this._getCacheExpiration(cacheName);
  201. await cacheExpiration.updateTimestamp(request.url);
  202. await cacheExpiration.expireEntries();
  203. }
  204. /**
  205. * This is a helper method that performs two operations:
  206. *
  207. * - Deletes *all* the underlying Cache instances associated with this plugin
  208. * instance, by calling caches.delete() on you behalf.
  209. * - Deletes the metadata from IndexedDB used to keep track of expiration
  210. * details for each Cache instance.
  211. *
  212. * When using cache expiration, calling this method is preferable to calling
  213. * `caches.delete()` directly, since this will ensure that the IndexedDB
  214. * metadata is also cleanly removed and open IndexedDB instances are deleted.
  215. *
  216. * Note that if you're *not* using cache expiration for a given cache, calling
  217. * `caches.delete()` and passing in the cache's name should be sufficient.
  218. * There is no Workbox-specific method needed for cleanup in that case.
  219. */
  220. async deleteCacheAndMetadata() {
  221. // Do this one at a time instead of all at once via `Promise.all()` to
  222. // reduce the chance of inconsistency if a promise rejects.
  223. for (const [cacheName, cacheExpiration] of this._cacheExpirations) {
  224. await caches.delete(cacheName);
  225. await cacheExpiration.delete();
  226. }
  227. // Reset this._cacheExpirations to its initial state.
  228. this._cacheExpirations = new Map();
  229. }
  230. }
  231. export {Plugin};