StrategyHandler.js 21 KB

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