Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] Update plugins and strategies to be more reusable and extensible #2446

Closed
philipwalton opened this issue Apr 8, 2020 · 2 comments
Closed

Comments

@philipwalton
Copy link
Member

Objective

Workbox comes with a set of plugins and strategies that are very easy to use (and use together), but if a developer wants a slightly different behavior from what we provide out of the box, they often have to start from scratch.

This proposal consolidates how build strategies work internally and exposes that logic to third-party developers, creating an idiomatic way to extend Workbox with the goal of engendering a more active third-party ecosystem.

Background

Today, the primary way developers can customize or extend Workbox is to use plugins. Plugins work well for slight modifications to the behavior of existing strategies, but they don't work well in cases where a callback need to coordinate with other callbacks, and they don't work at all if the developer is writing their own custom route handler.

Furthermore, plugins today do not capture the full response lifecycle (e.g. there's no way to know when all the work done by a strategy is complete).

To address these shortcomings, this proposal updates our strategy and plugin infrastructure to enable the following new capabilities:

  • Make it possible for 3rd-party developers to write their own strategies that also integrate with the existing plugin ecosystem.
  • Add new plugin callbacks that better capture the entire strategy lifecycle (e.g. from beginning to end, including work passed to event.waitUntil()).
  • Make it easier for developers to use plugins to observe strategy responses (e.g. to gather analytics on cache-hit rate, cache lookup speed, network download, etc.)
  • Make it possible for developers writing their own strategies to expose their own plugin lifecycle hooks (not sure how many users will want this, but it would be possible with the proposed changes).

Overview

The best way to ensure third-party developers have the power to extend Workbox in ways that fully meet their needs is to base our own strategies on top of the extensibility mechanisms we expose to third-party developers.

Specifically, this proposal introduces a new way for third-party developers to define their own Workbox strategies, and all of our built-in strategies will be rewritten on top of this mechanism.

New strategy base class

With this proposal, all Workbox strategy classes (both built-in strategies as well as custom, third-party strategies) must extend the new Strategy base class.

The Strategy base class is responsible for two primary things:

  • Invoking plugin lifecycle callbacks common to all strategy handlers (e.g. when they start, respond, and end).
  • Creating a "handler" instance, that can manage state for each individual request a strategy is handling.

A new "handler" class

We currently have internal modules call fetchWrapper and cacheWrapper, which (as their name implies) wrap the various fetch and cache APIs with hooks into their lifecycle. This is the mechanism that currently allows plugins to work, but it's not exposed to developers.

The new "handler" class (which this proposal calls StrategyHandler) will expose these methods so custom strategies can call fetch() or cacheMatch() and have any plugins that were added to the strategy instance automatically invoked.

This class would also make it possible for developers to add their own custom, lifecycle callbacks that might be specific to their strategies, and they would "just work" with the existing plugin interface.

New plugin lifecycle state

In Workbox today, plugins are stateless. That means if a request for /index.html triggers both the requestWillFetch() and cachedResponseWillBeUsed() callbacks, those two callbacks have no way of communicating with each other or even knowing that they were triggered by the same request.

In this proposal, all plugin callbacks will also be passed a new state object. This state object will be unique to this particular plugin object and this particular strategy invocation (i.e. the call to handle()).

This allows developers to write plugins where one callback can conditionally do something based on what another callback in the same plugin did (e.g. compute the time delta between running requestWillFetch() and fetchDidSucceed or fetchDidFail()).

New plugin lifecycle callbacks

In order to fully leverage the plugin lifecycle state (mentioned above), you need to know when the lifecycle of a given strategy invocation starts and finishes.

To address this need (and others), the following new plugin lifecycle callbacks will be added:

  • handlerWillStart: called before any handler logic starts running. This callback can be used to set the initial handler state (e.g. record the start time).
  • handlerWillRespond: called before the strategies handle() method returns a response. This callback can be used to modify that response before returning it to a route handler or other custom logic.
  • handlerDidRespond: called after the strategy's handle() method returns a response. This callback can be used to record any final response details, e.g. after changes made by other plugins.
  • handlerDidComplete: called after all extend lifetime promises added to the event from the invocation of this strategy have settled. This callback can be used to report on any data that needs to wait until the handler is done in order to calculate (e.g. cache hit status, cache latency, network latency).

Developers implementing their own custom strategies do not have to worry about invoking these callbacks themselves; that's all handled by a new Strategy base class.

API Design

This proposal would add two new classes to the API of the workbox-strategy package:

  • Strategy
  • StrategyHandler

Strategy class

An abstract base class that all other strategy classes must extend from:

abstract class Strategy {
  cacheName: string;
  plugins: WorkboxPlugin[];
  fetchOptions?: RequestInit;
  matchOptions?: CacheQueryOptions;

  /**
   * Classes extending the `Strategy` based class should implement this method,
   * and leverage the [`handler`]{@link module:workbox-strategies.StrategyHandler}
   * arg to perform all fetching and cache logic, which will ensure all relevant
   * cache, cache options, fetch options and plugins are used (per the current
   * strategy instance).
   */
  protected abstract _handle(
    request: Request,
    handler: StrategyHandler
  ): Promise<Response>;

  /**
   * 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.
   */
  constructor(options: {
    cacheName?: string;
    plugins?: WorkboxPlugin[];
    fetchOptions?: RequestInit;
    matchOptions?: CacheQueryOptions;
  } = {});

  /**
   * Perform a request strategy and returns a `Promise` that will resolve with
   * a `Response`, invoking all relevant plugin callbacks.
   *
   * When a strategy instance is registered with a Workbox
   * [route]{@link module:workbox-routing.Route}, this method is automatically
   * called when the route matches.
   *
   * Alternatively, this method can be used in a standalone `FetchEvent` 
   * listener by passing it to `event.respondWith()`.
   */
  handle(
    options: FetchEvent | RouteHandlerCallbackOptions,
  ): Promise<Response>;

  /**
   * Similar to [`handle()`]{@link module:workbox-strategies.Strategy~handle}, but
   * instead of just returning a `Promise` that resolves to a `Response` it
   * it will return an tuple of [response, done] promises, where the former
   * (`response`) is equivalent to what `handle()` returns, and the latter is a
   * Promise that will resolve once any promises that were added to
   * `event.waitUntil()` as 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.
   */
  handleAll(
    options: FetchEvent | RouteHandlerCallbackOptions
  ): [Promise<Response>, Promise<void>];
}

StrategyHandler class

A class created every time a Strategy instance instance calls handle() or handleAll() that wraps all fetch and cache actions around plugin callbacks and keeps track of when the strategy is "done" (i.e. all added event.waitUntil() promises have resolved).

class StrategyHandler {
  public request!: Request;
  public url?: URL;
  public event?: ExtendableEvent;
  public params?: any;

  /**
   * 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.
   */
  constructor(
    strategy: Strategy,
    options: {
      request: Request;
      url?: URL;
      params?: any;
      event?: ExtendableEvent;
    },
  );
  
  /**
   * Fetches a given request (and invokes any applicable plugin callback
   * methods) using the `fetchOptions` and `plugins` defined on the strategy
   * object.
   *
   * The following plugin lifecycle methods are invoked when using this method:
   * - `requestWillFetch()`
   * - `fetchDidSucceed()`
   * - `fetchDidFail()`
   */
  fetch(input: RequestInfo): Promise<Response>;

  /**
   * Matches a request from the cache (and invokes any applicable plugin
   * callback methods) using the `cacheName`, `matchOptions`, and `plugins`
   * defined on the strategy object.
   *
   * The following plugin lifecycle methods are invoked when using this method:
   * - cacheKeyWillByUsed()
   * - cachedResponseWillByUsed()
   */
  cacheMatch(key: RequestInfo): Promise<Response | undefined>;

  /**
   * Puts a request/response pair in the cache (and invokes any applicable
   * plugin callback methods) using the `cacheName` and `plugins` defined on
   * the strategy object.
   *
   * The following plugin lifecycle methods are invoked when using this method:
   * - cacheKeyWillByUsed()
   * - cacheWillUpdate()
   * - cacheDidUpdate()
   */
  cachePut(key: RequestInfo, response: Response): Promise<void>;

  /**
   * Calls `this.fetch()` and (in the background) runs `this.cachePut()` on
   * the response generated by `this.fetch()`.
   *
   * The call to `this.cachePut()` automatically invokes `this.waitUntil()`,
   * so you do not have to manually call `waitUntil()` on the event.
   *
   */
  fetchAndCachePut(input: RequestInfo): Promise<Response>;

  /**
   * Returns true if the strategy has at least one plugin with the given
   * callback.
   */
  hasCallback<C extends keyof WorkboxPlugin>(name: C): boolean;

  /**
   * Runs all plugin callbacks matching the given name, in order, passing the
   * given param object (merged ith the current plugin state) 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 `iterateCallbacks()` below for how to handle that 
   * case.
   */
  runCallbacks<C extends keyof NonNullable<WorkboxPlugin>>(
    name: C,
    param: Omit<WorkboxPluginCallbackParam[C], 'state'>,
  ): Promise<void>;

  /**
   * Accepts a callback and returns an iterable of matching plugin callbacks,
   * where each callback is wrapped with the current handler state (i.e. when
   * you call each callback, whatever object parameter you pass it will
   * be merged with the plugin's current state).
   */
  *iterateCallbacks<C extends keyof WorkboxPlugin>(
    name: C,
  ): Generator<NonNullable<WorkboxPlugin[C]>>;

    /**
   * Adds a promise to the extend lifetime promises of the event event
   * associated with the request being handled (usually a `FetchEvent`).
   *
   * Note: you can await `doneWaiting()` to know when all added promises have 
   * settled.
   */
  waitUntil(promise: Promise<any>): Promise<any>;

  /**
   * Returns a promise that resolves once all promises passed to `waitUntil()`
   * have settled.
   *
   * Note: any work done after `doneWaiting()` settles should be manually
   * passed to an event's `waitUntil()` method (not this handler's
   * `waitUntil()` method), otherwise the service worker thread my be killed
   * prior to your work completing.
   */
  doneWaiting(): Promise<void>;

  /**
   * Stops running the strategy and immediately resolves any pending
   * `waitUntil()` promises.
   */
  destroy(): void
}

Example usage

Perhaps the easiest way to understand this proposal is to take a look at how a developer wanting to build a custom strategy will do so. (Note: built-in strategies will use this same mechanism)

To define your own strategy class, all you have to do is:

  1. Import the Strategy base class form the workbox-strategies package,
  2. Define a new, custom class that extends the Strategy base class.
  3. Implement the _handle() method with your own handling logic.

A simplest example:

This example shows how we'd re-implement our NetworkOnly strategy if this proposal were implemented (logging code omitted):

import {Strategy} from 'workbox-strategies';

class NetworkOnly extends Strategy {
  _handle(request, handler) {
    return handler.fetch(request);
  }
}

By not defining a constructor function, this strategy inherits the base Strategy constructor, which accepts a {cacheName, plugins, fetchOptions, matchOptions} param and sets those values on the instance.

The _handle() is invoked with two parameters

  • request, which is the Request the strategy is going to return a response for
  • handler a StrategyHandler instance, automatically created for the current strategy.

Note, in this example, rather than calling the native fetch() method, it's calling handler.fetch(), which automatically call all plugin lifecycle callbacks associated with fetching a resource.

A more complex example:

This example shows how a third-party developer could define their own strategy. This example is based on cache-network-race from the Offline Cookbook (which Workbox does not provide), but goes a step further and always updates the cache after a successful network request.

import {Strategy} from 'workbox-strategies';

class CacheNetworkRace extends Strategy {
  _handle(request, handler) {
    const fetchAndCachePutDone = handler.fetchAndCachePut(request);
    const cacheMatchDone = handler.cacheMatch(request); 

    return new Promise((resolve, reject) => {
      fetchAndCachePutDone.then(resolve);
      cacheMatchDone.then((response) => response && resolve(response));

      // Reject if both network and cache error or find no response.
      Promise.allSettled([fetchAndCachePutDone, cacheMatchDone]).then((results) => {
        const [fetchAndCachePutResult, cacheMatchResult] = results;
        if (fetchAndCachePutResult.status === 'rejected' && !cacheMatchResult.value) {
          reject(fetchAndCachePutResult.reason);
        }  
      });
    });
  }
}

An example plugin:

The example shows how you can use the new state property of each plugin callback param to coordinate between plugins across the entire response lifecycle.

The plugin below can be used with strategies that provide a cache-first response but also hit the network (e.g. CacheFirst and StaleWhileRevalidate). It reports on whether or not there was a cache hit, and if not it calculates how long the fetch took.

A plugin like this could be used to track the performance of various service worker response strategies and send that data to an analytics tool.

const cacheFirstPerformanceReportPlugin = {
  requestWillFetch: async ({request, state}) => {
    // Store the start time of the request (if run).
    state.fetchStartTime = performance.now();
    return request;
  },
  fetchDidSucceed: async ({response, state}) => {
    // Store the end time of the request (if run).
    state.fetchEndTime = performance.now();
    return response;
  },
  cachedResponseWillBeUsed: async ({cachedResponse, state}) => {
    if (cachedResponse) {
      // Store the cache hit status.
      state.cacheHit = true;
    }
  },
  handlerDidComplete: async ({request, state}) => {
    if (state.cacheHit) {
      console.log(request.url, `Response was fulfilled from the cache`);
    } else {
      const fetchTime = state.fetchEndTime - state.fetchStartTime;
      console.log(request.url, `Response not in the cache, fetch took ${fetchTime} ms`);
    }
  },
};
@petecarapetyan
Copy link

+1

@philipwalton
Copy link
Member Author

Added via #2455.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants