/* Copyright 2020 Google LLC Use of this source code is governed by an MIT-style license that can be found in the LICENSE file or at https://opensource.org/licenses/MIT. */ import type { Route } from "../../Route.js"; import type { HandlerCallbackOptions, MapLikeObject, SerwistPlugin, SerwistPluginCallbackParam } from "../../types.js"; import { assert } from "../../utils/assert.js"; import { cacheMatchIgnoreParams } from "../../utils/cacheMatchIgnoreParams.js"; import { Deferred } from "../../utils/Deferred.js"; import { executeQuotaErrorCallbacks } from "../../utils/executeQuotaErrorCallbacks.js"; import { getFriendlyURL } from "../../utils/getFriendlyURL.js"; import { logger } from "../../utils/logger.js"; import { SerwistError } from "../../utils/SerwistError.js"; import { timeout } from "../../utils/timeout.js"; import type { Strategy } from "./Strategy.js"; function toRequest(input: RequestInfo) { return typeof input === "string" ? new Request(input) : input; } /** * A class created every time a {@linkcode Strategy} instance calls {@linkcode Strategy.handle} or * {@linkcode Strategy.handleAll} that wraps all fetch and cache actions around plugin callbacks * and keeps track of when the strategy is "done" (i.e. when all added `event.waitUntil()` promises * have resolved). */ export class StrategyHandler { /** * The event associated with this request. */ public event: ExtendableEvent; /** * The request the strategy is processing (passed to the strategy's * `handle()` or `handleAll()` method). */ public request: Request; /** * A `URL` instance of `request.url` (if passed to the strategy's * `handle()` or `handleAll()` method). * Note: the `url` param will be present if the strategy is invoked * from a {@linkcode Route} object. */ public url?: URL; /** * Some additional params (if passed to the strategy's * `handle()` or `handleAll()` method). * * Note: the `params` param will be present if the strategy is invoked * from a {@linkcode Route} object and that route's matcher returned a truthy * value (it will be that value). */ public params?: string[] | MapLikeObject; private _cacheKeys: Record = {}; private readonly _strategy: Strategy; private readonly _handlerDeferred: Deferred; private readonly _extendLifetimePromises: Promise[]; private readonly _plugins: SerwistPlugin[]; private readonly _pluginStateMap: Map; /** * Creates a new instance associated with the passed strategy and event * that's handling the request. * * The constructor also initializes the state that will be passed to each of * the plugins handling this request. * * @param strategy * @param options */ constructor( strategy: Strategy, options: HandlerCallbackOptions & { request: HandlerCallbackOptions["request"] & Request; }, ) { if (process.env.NODE_ENV !== "production") { assert!.isInstance(options.event, ExtendableEvent, { moduleName: "serwist", className: "StrategyHandler", funcName: "constructor", paramName: "options.event", }); assert!.isInstance(options.request, Request, { moduleName: "serwist", className: "StrategyHandler", funcName: "constructor", paramName: "options.request", }); } this.event = options.event; this.request = options.request; if (options.url) { this.url = options.url; this.params = options.params; } this._strategy = strategy; this._handlerDeferred = new Deferred(); this._extendLifetimePromises = []; // Copy the plugins list (since it's mutable on the strategy), // so any mutations don't affect this handler instance. this._plugins = [...strategy.plugins]; this._pluginStateMap = new Map(); for (const plugin of this._plugins) { this._pluginStateMap.set(plugin, {}); } this.event.waitUntil(this._handlerDeferred.promise); } /** * Fetches a given request (and invokes any applicable plugin callback * methods), taking the `fetchOptions` (for non-navigation requests) and * `plugins` provided to the {@linkcode Strategy} object into account. * * The following plugin lifecycle methods are invoked when using this method: * - `requestWillFetch()` * - `fetchDidSucceed()` * - `fetchDidFail()` * * @param input The URL or request to fetch. * @returns */ async fetch(input: RequestInfo): Promise { const { event } = this; let request: Request = toRequest(input); const preloadResponse = await this.getPreloadResponse(); if (preloadResponse) { return preloadResponse; } // If there is a fetchDidFail plugin, we need to save a clone of the // original request before it's either modified by a requestWillFetch // plugin or before the original request's body is consumed via fetch(). const originalRequest = this.hasCallback("fetchDidFail") ? request.clone() : null; try { for (const cb of this.iterateCallbacks("requestWillFetch")) { request = await cb({ request: request.clone(), event }); } } catch (err) { if (err instanceof Error) { throw new SerwistError("plugin-error-request-will-fetch", { thrownErrorMessage: err.message, }); } } // The request can be altered by plugins with `requestWillFetch` making // the original request (most likely from a `fetch` event) different // from the Request we make. Pass both to `fetchDidFail` to aid debugging. const pluginFilteredRequest: Request = request.clone(); try { let fetchResponse: Response; // See https://github.com/GoogleChrome/workbox/issues/1796 fetchResponse = await fetch(request, request.mode === "navigate" ? undefined : this._strategy.fetchOptions); if (process.env.NODE_ENV !== "production") { logger.debug(`Network request for '${getFriendlyURL(request.url)}' returned a response with status '${fetchResponse.status}'.`); } for (const callback of this.iterateCallbacks("fetchDidSucceed")) { fetchResponse = await callback({ event, request: pluginFilteredRequest, response: fetchResponse, }); } return fetchResponse; } catch (error) { if (process.env.NODE_ENV !== "production") { logger.log(`Network request for '${getFriendlyURL(request.url)}' threw an error.`, error); } // `originalRequest` will only exist if a `fetchDidFail` callback // is being used (see above). if (originalRequest) { await this.runCallbacks("fetchDidFail", { error: error as Error, event, originalRequest: originalRequest.clone(), request: pluginFilteredRequest.clone(), }); } throw error; } } /** * Calls `this.fetch()` and (in the background) caches the generated response. * * The call to `this.cachePut()` automatically invokes `this.waitUntil()`, * so you do not have to call `waitUntil()` yourself. * * @param input The request or URL to fetch and cache. * @returns */ async fetchAndCachePut(input: RequestInfo): Promise { const response = await this.fetch(input); const responseClone = response.clone(); void this.waitUntil(this.cachePut(input, responseClone)); return response; } /** * Matches a request from the cache (and invokes any applicable plugin * callback method) using the `cacheName`, `matchOptions`, and `plugins` * provided to the `Strategy` object. * * The following lifecycle methods are invoked when using this method: * - `cacheKeyWillBeUsed` * - `cachedResponseWillBeUsed` * * @param key The `Request` or `URL` object to use as the cache key. * @returns A matching response, if found. */ async cacheMatch(key: RequestInfo): Promise { const request: Request = toRequest(key); let cachedResponse: Response | undefined; const { cacheName, matchOptions } = this._strategy; const effectiveRequest = await this.getCacheKey(request, "read"); const multiMatchOptions = { ...matchOptions, ...{ cacheName } }; cachedResponse = await caches.match(effectiveRequest, multiMatchOptions); if (process.env.NODE_ENV !== "production") { if (cachedResponse) { logger.debug(`Found a cached response in '${cacheName}'.`); } else { logger.debug(`No cached response found in '${cacheName}'.`); } } for (const callback of this.iterateCallbacks("cachedResponseWillBeUsed")) { cachedResponse = (await callback({ cacheName, matchOptions, cachedResponse, request: effectiveRequest, event: this.event, })) || undefined; } return cachedResponse; } /** * Puts a request/response pair into the cache (and invokes any applicable * plugin callback method) using the `cacheName` and `plugins` provided to * the {@linkcode Strategy} object. * * The following plugin lifecycle methods are invoked when using this method: * - `cacheKeyWillBeUsed` * - `cacheWillUpdate` * - `cacheDidUpdate` * * @param key The request or URL to use as the cache key. * @param response The response to cache. * @returns `false` if a `cacheWillUpdate` caused the response to * not be cached, and `true` otherwise. */ async cachePut(key: RequestInfo, response: Response): Promise { const request: Request = toRequest(key); // Run in the next task to avoid blocking other cache reads. // https://github.com/w3c/ServiceWorker/issues/1397 await timeout(0); const effectiveRequest = await this.getCacheKey(request, "write"); if (process.env.NODE_ENV !== "production") { if (effectiveRequest.method && effectiveRequest.method !== "GET") { throw new SerwistError("attempt-to-cache-non-get-request", { url: getFriendlyURL(effectiveRequest.url), method: effectiveRequest.method, }); } } if (!response) { if (process.env.NODE_ENV !== "production") { logger.error(`Cannot cache non-existent response for '${getFriendlyURL(effectiveRequest.url)}'.`); } throw new SerwistError("cache-put-with-no-response", { url: getFriendlyURL(effectiveRequest.url), }); } const responseToCache = await this._ensureResponseSafeToCache(response); if (!responseToCache) { if (process.env.NODE_ENV !== "production") { logger.debug(`Response '${getFriendlyURL(effectiveRequest.url)}' will not be cached.`, responseToCache); } return false; } const { cacheName, matchOptions } = this._strategy; const cache = await self.caches.open(cacheName); if (process.env.NODE_ENV !== "production") { // See https://github.com/GoogleChrome/workbox/issues/2818 const vary = response.headers.get("Vary"); if (vary && matchOptions?.ignoreVary !== true) { logger.debug( `The response for ${getFriendlyURL( effectiveRequest.url, )} has a 'Vary: ${vary}' header. Consider setting the {ignoreVary: true} option on your strategy to ensure cache matching and deletion works as expected.`, ); } } const hasCacheUpdateCallback = this.hasCallback("cacheDidUpdate"); const oldResponse = hasCacheUpdateCallback ? await cacheMatchIgnoreParams( // TODO(philipwalton): the `__WB_REVISION__` param is a precaching // feature. Consider into ways to only add this behavior if using // precaching. cache, effectiveRequest.clone(), ["__WB_REVISION__"], matchOptions, ) : null; if (process.env.NODE_ENV !== "production") { logger.debug(`Updating the '${cacheName}' cache with a new Response for ${getFriendlyURL(effectiveRequest.url)}.`); } try { await cache.put(effectiveRequest, hasCacheUpdateCallback ? responseToCache.clone() : responseToCache); } catch (error) { if (error instanceof Error) { // See https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-QuotaExceededError if (error.name === "QuotaExceededError") { await executeQuotaErrorCallbacks(); } throw error; } } for (const callback of this.iterateCallbacks("cacheDidUpdate")) { await callback({ cacheName, oldResponse, newResponse: responseToCache.clone(), request: effectiveRequest, event: this.event, }); } return true; } /** * Checks the `plugins` provided to the {@linkcode Strategy} object for `cacheKeyWillBeUsed` * callbacks and executes found callbacks in sequence. The final `Request` * object returned by the last plugin is treated as the cache key for cache * reads and/or writes. If no `cacheKeyWillBeUsed` plugin callbacks have * been registered, the passed request is returned unmodified. * * @param request * @param mode * @returns */ async getCacheKey(request: Request, mode: "read" | "write"): Promise { const key = `${request.url} | ${mode}`; if (!this._cacheKeys[key]) { let effectiveRequest = request; for (const callback of this.iterateCallbacks("cacheKeyWillBeUsed")) { effectiveRequest = toRequest( await callback({ mode, request: effectiveRequest, event: this.event, params: this.params, }), ); } this._cacheKeys[key] = effectiveRequest; } return this._cacheKeys[key]; } /** * Returns `true` if the strategy has at least one plugin with the given * callback. * * @param name The name of the callback to check for. * @returns */ hasCallback(name: C): boolean { for (const plugin of this._strategy.plugins) { if (name in plugin) { return true; } } return false; } /** * Runs all plugin callbacks matching the given name, in order, passing the * given param object as the only argument. * * Note: since this method runs all plugins, it's not suitable for cases * where the return value of a callback needs to be applied prior to calling * the next callback. See {@linkcode StrategyHandler.iterateCallbacks} for how to handle that case. * * @param name The name of the callback to run within each plugin. * @param param The object to pass as the first (and only) param when executing each callback. This object will be merged with the * current plugin state prior to callback execution. */ async runCallbacks>(name: C, param: Omit): Promise { for (const callback of this.iterateCallbacks(name)) { // TODO(philipwalton): not sure why `any` is needed. It seems like // this should work with `as SerwistPluginCallbackParam[C]`. await callback(param as any); } } /** * Accepts a callback name and returns an iterable of matching plugin callbacks. * * @param name The name fo the callback to run * @returns */ *iterateCallbacks(name: C): Generator> { for (const plugin of this._strategy.plugins) { if (typeof plugin[name] === "function") { const state = this._pluginStateMap.get(plugin); const statefulCallback = (param: Omit) => { const statefulParam = { ...param, state }; // TODO(philipwalton): not sure why `any` is needed. It seems like // this should work with `as WorkboxPluginCallbackParam[C]`. return plugin[name]!(statefulParam as any); }; yield statefulCallback as NonNullable; } } } /** * Adds a promise to the * [extend lifetime promises](https://w3c.github.io/ServiceWorker/#extendableevent-extend-lifetime-promises) * of the event event associated with the request being handled (usually a `FetchEvent`). * * Note: you can await {@linkcode StrategyHandler.doneWaiting} to know when all added promises have settled. * * @param promise A promise to add to the extend lifetime promises of * the event that triggered the request. */ waitUntil(promise: Promise): Promise { this._extendLifetimePromises.push(promise); return promise; } /** * Returns a promise that resolves once all promises passed to * `this.waitUntil()` have settled. * * Note: any work done after `doneWaiting()` settles should be manually * passed to an event's `waitUntil()` method (not `this.waitUntil()`), otherwise * the service worker thread may be killed prior to your work completing. */ async doneWaiting(): Promise { let promise: Promise | undefined; while ((promise = this._extendLifetimePromises.shift())) { await promise; } } /** * Stops running the strategy and immediately resolves any pending * `waitUntil()` promise. */ destroy(): void { this._handlerDeferred.resolve(null); } /** * This method checks if the navigation preload `Response` is available. * * @param request * @param event * @returns */ async getPreloadResponse(): Promise { if (this.event instanceof FetchEvent && this.event.request.mode === "navigate" && "preloadResponse" in this.event) { try { const possiblePreloadResponse = (await this.event.preloadResponse) as Response | undefined; if (possiblePreloadResponse) { if (process.env.NODE_ENV !== "production") { logger.log(`Using a preloaded navigation response for '${getFriendlyURL(this.event.request.url)}'`); } return possiblePreloadResponse; } } catch (error) { if (process.env.NODE_ENV !== "production") { logger.error(error); } return undefined; } } return undefined; } /** * This method will call `cacheWillUpdate` on the available plugins (or use * status === 200) to determine if the response is safe and valid to cache. * * @param response * @returns * @private */ async _ensureResponseSafeToCache(response: Response): Promise { let responseToCache: Response | undefined = response; let pluginsUsed = false; for (const callback of this.iterateCallbacks("cacheWillUpdate")) { responseToCache = (await callback({ request: this.request, response: responseToCache, event: this.event, })) || undefined; pluginsUsed = true; if (!responseToCache) { break; } } if (!pluginsUsed) { if (responseToCache && responseToCache.status !== 200) { if (process.env.NODE_ENV !== "production") { if (responseToCache.status === 0) { logger.warn( `The response for '${this.request.url}' is an opaque response. The caching strategy that you're using will not cache opaque responses by default.`, ); } else { logger.debug(`The response for '${this.request.url}' returned a status code of '${response.status}' and won't be cached as a result.`); } } responseToCache = undefined; } } return responseToCache; } }