serwist
Version:
A Swiss Army knife for service workers.
568 lines (508 loc) • 19.8 kB
text/typescript
/*
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, MapLikeObject, SerwistPlugin, SerwistPluginCallbackParam } from "../../types.js";
import { Deferred } from "../../utils/Deferred.js";
import { SerwistError } from "../../utils/SerwistError.js";
import { assert } from "../../utils/assert.js";
import { cacheMatchIgnoreParams } from "../../utils/cacheMatchIgnoreParams.js";
import { executeQuotaErrorCallbacks } from "../../utils/executeQuotaErrorCallbacks.js";
import { getFriendlyURL } from "../../utils/getFriendlyURL.js";
import { logger } from "../../utils/logger.js";
import { timeout } from "../../utils/timeout.js";
import type { Strategy } from "./Strategy.js";
import type { Route } from "../../Route.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<string, Request> = {};
private readonly _strategy: Strategy;
private readonly _handlerDeferred: Deferred<any>;
private readonly _extendLifetimePromises: Promise<any>[];
private readonly _plugins: SerwistPlugin[];
private readonly _pluginStateMap: Map<SerwistPlugin, MapLikeObject>;
/**
* 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<Response> {
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<Response> {
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<Response | undefined> {
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<boolean> {
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<Request> {
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<C extends keyof SerwistPlugin>(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<C extends keyof NonNullable<SerwistPlugin>>(name: C, param: Omit<SerwistPluginCallbackParam[C], "state">): Promise<void> {
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<C extends keyof SerwistPlugin>(name: C): Generator<NonNullable<SerwistPlugin[C]>> {
for (const plugin of this._strategy.plugins) {
if (typeof plugin[name] === "function") {
const state = this._pluginStateMap.get(plugin);
const statefulCallback = (param: Omit<SerwistPluginCallbackParam[C], "state">) => {
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<SerwistPlugin[C]>;
}
}
}
/**
* 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<T>(promise: Promise<T>): Promise<T> {
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<void> {
let promise: Promise<any> | undefined = 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<Response | undefined> {
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<Response | undefined> {
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;
}
}