StrategyHandler.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. /*
  2. Copyright 2020 Google LLC
  3. Use of this source code is governed by an MIT-style
  4. license that can be found in the LICENSE file or at
  5. https://opensource.org/licenses/MIT.
  6. */
  7. import { assert } from 'workbox-core/_private/assert.js';
  8. import { cacheMatchIgnoreParams } from 'workbox-core/_private/cacheMatchIgnoreParams.js';
  9. import { Deferred } from 'workbox-core/_private/Deferred.js';
  10. import { executeQuotaErrorCallbacks } from 'workbox-core/_private/executeQuotaErrorCallbacks.js';
  11. import { getFriendlyURL } from 'workbox-core/_private/getFriendlyURL.js';
  12. import { logger } from 'workbox-core/_private/logger.js';
  13. import { timeout } from 'workbox-core/_private/timeout.js';
  14. import { WorkboxError } from 'workbox-core/_private/WorkboxError.js';
  15. import './_version.js';
  16. function toRequest(input) {
  17. return typeof input === 'string' ? new Request(input) : input;
  18. }
  19. /**
  20. * A class created every time a Strategy instance instance calls
  21. * [handle()]{@link module:workbox-strategies.Strategy~handle} or
  22. * [handleAll()]{@link module:workbox-strategies.Strategy~handleAll} that wraps all fetch and
  23. * cache actions around plugin callbacks and keeps track of when the strategy
  24. * is "done" (i.e. all added `event.waitUntil()` promises have resolved).
  25. *
  26. * @memberof module:workbox-strategies
  27. */
  28. class StrategyHandler {
  29. /**
  30. * Creates a new instance associated with the passed strategy and event
  31. * that's handling the request.
  32. *
  33. * The constructor also initializes the state that will be passed to each of
  34. * the plugins handling this request.
  35. *
  36. * @param {module:workbox-strategies.Strategy} strategy
  37. * @param {Object} options
  38. * @param {Request|string} options.request A request to run this strategy for.
  39. * @param {ExtendableEvent} options.event The event associated with the
  40. * request.
  41. * @param {URL} [options.url]
  42. * @param {*} [options.params]
  43. * [match callback]{@link module:workbox-routing~matchCallback},
  44. * (if applicable).
  45. */
  46. constructor(strategy, options) {
  47. this._cacheKeys = {};
  48. /**
  49. * The request the strategy is performing (passed to the strategy's
  50. * `handle()` or `handleAll()` method).
  51. * @name request
  52. * @instance
  53. * @type {Request}
  54. * @memberof module:workbox-strategies.StrategyHandler
  55. */
  56. /**
  57. * The event associated with this request.
  58. * @name event
  59. * @instance
  60. * @type {ExtendableEvent}
  61. * @memberof module:workbox-strategies.StrategyHandler
  62. */
  63. /**
  64. * A `URL` instance of `request.url` (if passed to the strategy's
  65. * `handle()` or `handleAll()` method).
  66. * Note: the `url` param will be present if the strategy was invoked
  67. * from a workbox `Route` object.
  68. * @name url
  69. * @instance
  70. * @type {URL|undefined}
  71. * @memberof module:workbox-strategies.StrategyHandler
  72. */
  73. /**
  74. * A `param` value (if passed to the strategy's
  75. * `handle()` or `handleAll()` method).
  76. * Note: the `param` param will be present if the strategy was invoked
  77. * from a workbox `Route` object and the
  78. * [match callback]{@link module:workbox-routing~matchCallback} returned
  79. * a truthy value (it will be that value).
  80. * @name params
  81. * @instance
  82. * @type {*|undefined}
  83. * @memberof module:workbox-strategies.StrategyHandler
  84. */
  85. if (process.env.NODE_ENV !== 'production') {
  86. assert.isInstance(options.event, ExtendableEvent, {
  87. moduleName: 'workbox-strategies',
  88. className: 'StrategyHandler',
  89. funcName: 'constructor',
  90. paramName: 'options.event',
  91. });
  92. }
  93. Object.assign(this, options);
  94. this.event = options.event;
  95. this._strategy = strategy;
  96. this._handlerDeferred = new Deferred();
  97. this._extendLifetimePromises = [];
  98. // Copy the plugins list (since it's mutable on the strategy),
  99. // so any mutations don't affect this handler instance.
  100. this._plugins = [...strategy.plugins];
  101. this._pluginStateMap = new Map();
  102. for (const plugin of this._plugins) {
  103. this._pluginStateMap.set(plugin, {});
  104. }
  105. this.event.waitUntil(this._handlerDeferred.promise);
  106. }
  107. /**
  108. * Fetches a given request (and invokes any applicable plugin callback
  109. * methods) using the `fetchOptions` (for non-navigation requests) and
  110. * `plugins` defined on the `Strategy` object.
  111. *
  112. * The following plugin lifecycle methods are invoked when using this method:
  113. * - `requestWillFetch()`
  114. * - `fetchDidSucceed()`
  115. * - `fetchDidFail()`
  116. *
  117. * @param {Request|string} input The URL or request to fetch.
  118. * @return {Promise<Response>}
  119. */
  120. async fetch(input) {
  121. const { event } = this;
  122. let request = toRequest(input);
  123. if (request.mode === 'navigate' &&
  124. event instanceof FetchEvent &&
  125. event.preloadResponse) {
  126. const possiblePreloadResponse = (await event.preloadResponse);
  127. if (possiblePreloadResponse) {
  128. if (process.env.NODE_ENV !== 'production') {
  129. logger.log(`Using a preloaded navigation response for ` +
  130. `'${getFriendlyURL(request.url)}'`);
  131. }
  132. return possiblePreloadResponse;
  133. }
  134. }
  135. // If there is a fetchDidFail plugin, we need to save a clone of the
  136. // original request before it's either modified by a requestWillFetch
  137. // plugin or before the original request's body is consumed via fetch().
  138. const originalRequest = this.hasCallback('fetchDidFail')
  139. ? request.clone()
  140. : null;
  141. try {
  142. for (const cb of this.iterateCallbacks('requestWillFetch')) {
  143. request = await cb({ request: request.clone(), event });
  144. }
  145. }
  146. catch (err) {
  147. if (err instanceof Error) {
  148. throw new WorkboxError('plugin-error-request-will-fetch', {
  149. thrownErrorMessage: err.message,
  150. });
  151. }
  152. }
  153. // The request can be altered by plugins with `requestWillFetch` making
  154. // the original request (most likely from a `fetch` event) different
  155. // from the Request we make. Pass both to `fetchDidFail` to aid debugging.
  156. const pluginFilteredRequest = request.clone();
  157. try {
  158. let fetchResponse;
  159. // See https://github.com/GoogleChrome/workbox/issues/1796
  160. fetchResponse = await fetch(request, request.mode === 'navigate' ? undefined : this._strategy.fetchOptions);
  161. if (process.env.NODE_ENV !== 'production') {
  162. logger.debug(`Network request for ` +
  163. `'${getFriendlyURL(request.url)}' returned a response with ` +
  164. `status '${fetchResponse.status}'.`);
  165. }
  166. for (const callback of this.iterateCallbacks('fetchDidSucceed')) {
  167. fetchResponse = await callback({
  168. event,
  169. request: pluginFilteredRequest,
  170. response: fetchResponse,
  171. });
  172. }
  173. return fetchResponse;
  174. }
  175. catch (error) {
  176. if (process.env.NODE_ENV !== 'production') {
  177. logger.log(`Network request for ` +
  178. `'${getFriendlyURL(request.url)}' threw an error.`, error);
  179. }
  180. // `originalRequest` will only exist if a `fetchDidFail` callback
  181. // is being used (see above).
  182. if (originalRequest) {
  183. await this.runCallbacks('fetchDidFail', {
  184. error: error,
  185. event,
  186. originalRequest: originalRequest.clone(),
  187. request: pluginFilteredRequest.clone(),
  188. });
  189. }
  190. throw error;
  191. }
  192. }
  193. /**
  194. * Calls `this.fetch()` and (in the background) runs `this.cachePut()` on
  195. * the response generated by `this.fetch()`.
  196. *
  197. * The call to `this.cachePut()` automatically invokes `this.waitUntil()`,
  198. * so you do not have to manually call `waitUntil()` on the event.
  199. *
  200. * @param {Request|string} input The request or URL to fetch and cache.
  201. * @return {Promise<Response>}
  202. */
  203. async fetchAndCachePut(input) {
  204. const response = await this.fetch(input);
  205. const responseClone = response.clone();
  206. void this.waitUntil(this.cachePut(input, responseClone));
  207. return response;
  208. }
  209. /**
  210. * Matches a request from the cache (and invokes any applicable plugin
  211. * callback methods) using the `cacheName`, `matchOptions`, and `plugins`
  212. * defined on the strategy object.
  213. *
  214. * The following plugin lifecycle methods are invoked when using this method:
  215. * - cacheKeyWillByUsed()
  216. * - cachedResponseWillByUsed()
  217. *
  218. * @param {Request|string} key The Request or URL to use as the cache key.
  219. * @return {Promise<Response|undefined>} A matching response, if found.
  220. */
  221. async cacheMatch(key) {
  222. const request = toRequest(key);
  223. let cachedResponse;
  224. const { cacheName, matchOptions } = this._strategy;
  225. const effectiveRequest = await this.getCacheKey(request, 'read');
  226. const multiMatchOptions = Object.assign(Object.assign({}, matchOptions), { cacheName });
  227. cachedResponse = await caches.match(effectiveRequest, multiMatchOptions);
  228. if (process.env.NODE_ENV !== 'production') {
  229. if (cachedResponse) {
  230. logger.debug(`Found a cached response in '${cacheName}'.`);
  231. }
  232. else {
  233. logger.debug(`No cached response found in '${cacheName}'.`);
  234. }
  235. }
  236. for (const callback of this.iterateCallbacks('cachedResponseWillBeUsed')) {
  237. cachedResponse =
  238. (await callback({
  239. cacheName,
  240. matchOptions,
  241. cachedResponse,
  242. request: effectiveRequest,
  243. event: this.event,
  244. })) || undefined;
  245. }
  246. return cachedResponse;
  247. }
  248. /**
  249. * Puts a request/response pair in the cache (and invokes any applicable
  250. * plugin callback methods) using the `cacheName` and `plugins` defined on
  251. * the strategy object.
  252. *
  253. * The following plugin lifecycle methods are invoked when using this method:
  254. * - cacheKeyWillByUsed()
  255. * - cacheWillUpdate()
  256. * - cacheDidUpdate()
  257. *
  258. * @param {Request|string} key The request or URL to use as the cache key.
  259. * @param {Response} response The response to cache.
  260. * @return {Promise<boolean>} `false` if a cacheWillUpdate caused the response
  261. * not be cached, and `true` otherwise.
  262. */
  263. async cachePut(key, response) {
  264. const request = toRequest(key);
  265. // Run in the next task to avoid blocking other cache reads.
  266. // https://github.com/w3c/ServiceWorker/issues/1397
  267. await timeout(0);
  268. const effectiveRequest = await this.getCacheKey(request, 'write');
  269. if (process.env.NODE_ENV !== 'production') {
  270. if (effectiveRequest.method && effectiveRequest.method !== 'GET') {
  271. throw new WorkboxError('attempt-to-cache-non-get-request', {
  272. url: getFriendlyURL(effectiveRequest.url),
  273. method: effectiveRequest.method,
  274. });
  275. }
  276. // See https://github.com/GoogleChrome/workbox/issues/2818
  277. const vary = response.headers.get('Vary');
  278. if (vary) {
  279. logger.debug(`The response for ${getFriendlyURL(effectiveRequest.url)} ` +
  280. `has a 'Vary: ${vary}' header. ` +
  281. `Consider setting the {ignoreVary: true} option on your strategy ` +
  282. `to ensure cache matching and deletion works as expected.`);
  283. }
  284. }
  285. if (!response) {
  286. if (process.env.NODE_ENV !== 'production') {
  287. logger.error(`Cannot cache non-existent response for ` +
  288. `'${getFriendlyURL(effectiveRequest.url)}'.`);
  289. }
  290. throw new WorkboxError('cache-put-with-no-response', {
  291. url: getFriendlyURL(effectiveRequest.url),
  292. });
  293. }
  294. const responseToCache = await this._ensureResponseSafeToCache(response);
  295. if (!responseToCache) {
  296. if (process.env.NODE_ENV !== 'production') {
  297. logger.debug(`Response '${getFriendlyURL(effectiveRequest.url)}' ` +
  298. `will not be cached.`, responseToCache);
  299. }
  300. return false;
  301. }
  302. const { cacheName, matchOptions } = this._strategy;
  303. const cache = await self.caches.open(cacheName);
  304. const hasCacheUpdateCallback = this.hasCallback('cacheDidUpdate');
  305. const oldResponse = hasCacheUpdateCallback
  306. ? await cacheMatchIgnoreParams(
  307. // TODO(philipwalton): the `__WB_REVISION__` param is a precaching
  308. // feature. Consider into ways to only add this behavior if using
  309. // precaching.
  310. cache, effectiveRequest.clone(), ['__WB_REVISION__'], matchOptions)
  311. : null;
  312. if (process.env.NODE_ENV !== 'production') {
  313. logger.debug(`Updating the '${cacheName}' cache with a new Response ` +
  314. `for ${getFriendlyURL(effectiveRequest.url)}.`);
  315. }
  316. try {
  317. await cache.put(effectiveRequest, hasCacheUpdateCallback ? responseToCache.clone() : responseToCache);
  318. }
  319. catch (error) {
  320. if (error instanceof Error) {
  321. // See https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-QuotaExceededError
  322. if (error.name === 'QuotaExceededError') {
  323. await executeQuotaErrorCallbacks();
  324. }
  325. throw error;
  326. }
  327. }
  328. for (const callback of this.iterateCallbacks('cacheDidUpdate')) {
  329. await callback({
  330. cacheName,
  331. oldResponse,
  332. newResponse: responseToCache.clone(),
  333. request: effectiveRequest,
  334. event: this.event,
  335. });
  336. }
  337. return true;
  338. }
  339. /**
  340. * Checks the list of plugins for the `cacheKeyWillBeUsed` callback, and
  341. * executes any of those callbacks found in sequence. The final `Request`
  342. * object returned by the last plugin is treated as the cache key for cache
  343. * reads and/or writes. If no `cacheKeyWillBeUsed` plugin callbacks have
  344. * been registered, the passed request is returned unmodified
  345. *
  346. * @param {Request} request
  347. * @param {string} mode
  348. * @return {Promise<Request>}
  349. */
  350. async getCacheKey(request, mode) {
  351. const key = `${request.url} | ${mode}`;
  352. if (!this._cacheKeys[key]) {
  353. let effectiveRequest = request;
  354. for (const callback of this.iterateCallbacks('cacheKeyWillBeUsed')) {
  355. effectiveRequest = toRequest(await callback({
  356. mode,
  357. request: effectiveRequest,
  358. event: this.event,
  359. // params has a type any can't change right now.
  360. params: this.params, // eslint-disable-line
  361. }));
  362. }
  363. this._cacheKeys[key] = effectiveRequest;
  364. }
  365. return this._cacheKeys[key];
  366. }
  367. /**
  368. * Returns true if the strategy has at least one plugin with the given
  369. * callback.
  370. *
  371. * @param {string} name The name of the callback to check for.
  372. * @return {boolean}
  373. */
  374. hasCallback(name) {
  375. for (const plugin of this._strategy.plugins) {
  376. if (name in plugin) {
  377. return true;
  378. }
  379. }
  380. return false;
  381. }
  382. /**
  383. * Runs all plugin callbacks matching the given name, in order, passing the
  384. * given param object (merged ith the current plugin state) as the only
  385. * argument.
  386. *
  387. * Note: since this method runs all plugins, it's not suitable for cases
  388. * where the return value of a callback needs to be applied prior to calling
  389. * the next callback. See
  390. * [`iterateCallbacks()`]{@link module:workbox-strategies.StrategyHandler#iterateCallbacks}
  391. * below for how to handle that case.
  392. *
  393. * @param {string} name The name of the callback to run within each plugin.
  394. * @param {Object} param The object to pass as the first (and only) param
  395. * when executing each callback. This object will be merged with the
  396. * current plugin state prior to callback execution.
  397. */
  398. async runCallbacks(name, param) {
  399. for (const callback of this.iterateCallbacks(name)) {
  400. // TODO(philipwalton): not sure why `any` is needed. It seems like
  401. // this should work with `as WorkboxPluginCallbackParam[C]`.
  402. await callback(param);
  403. }
  404. }
  405. /**
  406. * Accepts a callback and returns an iterable of matching plugin callbacks,
  407. * where each callback is wrapped with the current handler state (i.e. when
  408. * you call each callback, whatever object parameter you pass it will
  409. * be merged with the plugin's current state).
  410. *
  411. * @param {string} name The name fo the callback to run
  412. * @return {Array<Function>}
  413. */
  414. *iterateCallbacks(name) {
  415. for (const plugin of this._strategy.plugins) {
  416. if (typeof plugin[name] === 'function') {
  417. const state = this._pluginStateMap.get(plugin);
  418. const statefulCallback = (param) => {
  419. const statefulParam = Object.assign(Object.assign({}, param), { state });
  420. // TODO(philipwalton): not sure why `any` is needed. It seems like
  421. // this should work with `as WorkboxPluginCallbackParam[C]`.
  422. return plugin[name](statefulParam);
  423. };
  424. yield statefulCallback;
  425. }
  426. }
  427. }
  428. /**
  429. * Adds a promise to the
  430. * [extend lifetime promises]{@link https://w3c.github.io/ServiceWorker/#extendableevent-extend-lifetime-promises}
  431. * of the event event associated with the request being handled (usually a
  432. * `FetchEvent`).
  433. *
  434. * Note: you can await
  435. * [`doneWaiting()`]{@link module:workbox-strategies.StrategyHandler~doneWaiting}
  436. * to know when all added promises have settled.
  437. *
  438. * @param {Promise} promise A promise to add to the extend lifetime promises
  439. * of the event that triggered the request.
  440. */
  441. waitUntil(promise) {
  442. this._extendLifetimePromises.push(promise);
  443. return promise;
  444. }
  445. /**
  446. * Returns a promise that resolves once all promises passed to
  447. * [`waitUntil()`]{@link module:workbox-strategies.StrategyHandler~waitUntil}
  448. * have settled.
  449. *
  450. * Note: any work done after `doneWaiting()` settles should be manually
  451. * passed to an event's `waitUntil()` method (not this handler's
  452. * `waitUntil()` method), otherwise the service worker thread my be killed
  453. * prior to your work completing.
  454. */
  455. async doneWaiting() {
  456. let promise;
  457. while ((promise = this._extendLifetimePromises.shift())) {
  458. await promise;
  459. }
  460. }
  461. /**
  462. * Stops running the strategy and immediately resolves any pending
  463. * `waitUntil()` promises.
  464. */
  465. destroy() {
  466. this._handlerDeferred.resolve(null);
  467. }
  468. /**
  469. * This method will call cacheWillUpdate on the available plugins (or use
  470. * status === 200) to determine if the Response is safe and valid to cache.
  471. *
  472. * @param {Request} options.request
  473. * @param {Response} options.response
  474. * @return {Promise<Response|undefined>}
  475. *
  476. * @private
  477. */
  478. async _ensureResponseSafeToCache(response) {
  479. let responseToCache = response;
  480. let pluginsUsed = false;
  481. for (const callback of this.iterateCallbacks('cacheWillUpdate')) {
  482. responseToCache =
  483. (await callback({
  484. request: this.request,
  485. response: responseToCache,
  486. event: this.event,
  487. })) || undefined;
  488. pluginsUsed = true;
  489. if (!responseToCache) {
  490. break;
  491. }
  492. }
  493. if (!pluginsUsed) {
  494. if (responseToCache && responseToCache.status !== 200) {
  495. responseToCache = undefined;
  496. }
  497. if (process.env.NODE_ENV !== 'production') {
  498. if (responseToCache) {
  499. if (responseToCache.status !== 200) {
  500. if (responseToCache.status === 0) {
  501. logger.warn(`The response for '${this.request.url}' ` +
  502. `is an opaque response. The caching strategy that you're ` +
  503. `using will not cache opaque responses by default.`);
  504. }
  505. else {
  506. logger.debug(`The response for '${this.request.url}' ` +
  507. `returned a status code of '${response.status}' and won't ` +
  508. `be cached as a result.`);
  509. }
  510. }
  511. }
  512. }
  513. }
  514. return responseToCache;
  515. }
  516. }
  517. export { StrategyHandler };