/* 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 { HandlerCallbackOptions, RouteHandlerObject, SerwistPlugin } from "../../types.js"; import { cacheNames as privateCacheNames } from "../../utils/cacheNames.js"; import { getFriendlyURL } from "../../utils/getFriendlyURL.js"; import { logger } from "../../utils/logger.js"; import { SerwistError } from "../../utils/SerwistError.js"; import { StrategyHandler } from "./StrategyHandler.js"; export interface StrategyOptions { /** * Cache name to store and retrieve requests. Defaults to Serwist's default cache names. */ cacheName?: string; /** * [Plugins](https://serwist.pages.dev/docs/serwist/runtime-caching/plugins) to use in conjunction with this caching strategy. */ plugins?: SerwistPlugin[]; /** * Options passed to [non-navigation](https://github.com/GoogleChrome/workbox/issues/1796) `fetch()` calls made by * this strategy. */ fetchOptions?: RequestInit; /** * The [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions) * passed to any `cache.match()` or `cache.put()` call made by this strategy. */ matchOptions?: CacheQueryOptions; } /** * Abstract class for implementing runtime caching strategies. * * Custom strategies should extend this class and leverage `StrategyHandler`, which will ensure all relevant cache options, * fetch options, and plugins are used (per the current strategy instance), to perform all fetching and caching logic. */ export abstract class Strategy implements RouteHandlerObject { cacheName: string; plugins: SerwistPlugin[]; fetchOptions?: RequestInit; matchOptions?: CacheQueryOptions; protected abstract _handle(request: Request, handler: StrategyHandler): Promise; /** * Creates a new instance of the strategy and sets all documented option * properties as public instance properties. * * Note: if a custom strategy class extends the base Strategy class and does * not need more than these properties, it does not need to define its own * constructor. * * @param options */ constructor(options: StrategyOptions = {}) { this.cacheName = privateCacheNames.getRuntimeName(options.cacheName); this.plugins = options.plugins || []; this.fetchOptions = options.fetchOptions; this.matchOptions = options.matchOptions; } /** * Performs a request strategy and returns a promise that will resolve to * a response, invoking all relevant plugin callbacks. * * When a strategy instance is registered with a route, this method is automatically * called when the route matches. * * Alternatively, this method can be used in a standalone `fetch` event * listener by passing it to `event.respondWith()`. * * @param options A `FetchEvent` or an object with the properties listed below. * @param options.request A request to run this strategy for. * @param options.event The event associated with the request. * @param options.url * @param options.params */ handle(options: FetchEvent | HandlerCallbackOptions): Promise { const [responseDone] = this.handleAll(options); return responseDone; } /** * Similar to `handle()`, but instead of just returning a promise that * resolves to a response, it will return an tuple of `[response, done]` promises, * where `response` is equivalent to what `handle()` returns, and `done` is a * promise that will resolve once all promises added to `event.waitUntil()` as a part * of performing the strategy have completed. * * You can await the `done` promise to ensure any extra work performed by * the strategy (usually caching responses) completes successfully. * * @param options A `FetchEvent` or `HandlerCallbackOptions` object. * @returns A tuple of [response, done] promises that can be used to determine when the response resolves as * well as when the handler has completed all its work. */ handleAll(options: FetchEvent | HandlerCallbackOptions): [Promise, Promise] { // Allow for flexible options to be passed. if (options instanceof FetchEvent) { options = { event: options, request: options.request, }; } const event = options.event; const request = typeof options.request === "string" ? new Request(options.request) : options.request; const handler = new StrategyHandler(this, options.url ? { event, request, url: options.url, params: options.params } : { event, request }); const responseDone = this._getResponse(handler, request, event); const handlerDone = this._awaitComplete(responseDone, handler, request, event); // Return an array of promises, suitable for use with Promise.all(). return [responseDone, handlerDone]; } async _getResponse(handler: StrategyHandler, request: Request, event: ExtendableEvent): Promise { await handler.runCallbacks("handlerWillStart", { event, request }); let response: Response | undefined; try { response = await this._handle(request, handler); // The "official" Strategy subclasses all throw this error automatically, // but in case a third-party Strategy doesn't, ensure that we have a // consistent failure when there's no response or an error response. if (response === undefined || response.type === "error") { throw new SerwistError("no-response", { url: request.url }); } } catch (error) { if (error instanceof Error) { for (const callback of handler.iterateCallbacks("handlerDidError")) { response = await callback({ error, event, request }); if (response !== undefined) { break; } } } if (!response) { throw error; } if (process.env.NODE_ENV !== "production") { throw logger.log( `While responding to '${getFriendlyURL(request.url)}', an ${ error instanceof Error ? error.toString() : "" } error occurred. Using a fallback response provided by a handlerDidError plugin.`, ); } } for (const callback of handler.iterateCallbacks("handlerWillRespond")) { response = (await callback({ event, request, response })) as Response; } return response; } async _awaitComplete(responseDone: Promise, handler: StrategyHandler, request: Request, event: ExtendableEvent): Promise { let response: Response | undefined; let error: Error | undefined; try { response = await responseDone; } catch { // Ignore errors, as response errors should be caught via the `response` // promise above. The `done` promise will only throw for errors in // promises passed to `handler.waitUntil()`. } try { await handler.runCallbacks("handlerDidRespond", { event, request, response, }); await handler.doneWaiting(); } catch (waitUntilError) { if (waitUntilError instanceof Error) { error = waitUntilError; } } await handler.runCallbacks("handlerDidComplete", { event, request, response, error, }); handler.destroy(); if (error) { throw error; } } }