Caching firebase callable function requests with a service worker
Tags: pwa, firebase, serverless
Published: 2020-11-27 14:34:54

Recently, I had an idea for a quick project - [recipe.wtf](https://recipe.wtf). Given a url to a recipe, it scrapes the recipe text and ditches the ads, blogger's life story, etc... When I have an idea I want to spin up really quickly or prototype, I generally gravitate towards [Firebase](http://firebase.com/). It's super simple to set up, the documentation is pretty decent, and I find that it covers all of the bases for me.

## Background
Recipe.wtf consists of a static page (Firebase Hosting) and a single, idempotent function, an [HTTPS Callable](https://firebase.google.com/docs/functions/callable) (similar to AWS Lambda). After releasing the MVP, I decided to make Recipe.wtf a PWA. This post is about one of the problems I had to solve: making the firebase callable function requests available offline.

## Problem
Firebase callables make a POST request. [Workbox's caching strategies](https://developers.google.com/web/tools/workbox/modules/workbox-strategies) use the [ServiceWorker Cache API](https://w3c.github.io/ServiceWorker/#cache-put), which only supports GET requests.

## Solution
Write a custom caching strategy using [IndexedDB](https://developers.google.com/web/ilt/pwa/working-with-indexeddb)

## Setup
My firebase callable function is called `getRecipe`. It fetches the recipe url, parses the html, and returns the recipe text. The call to it from my React app is defined like this:

```js
const getRecipe = (params: { url: string }) =>
  firebase.functions().httpsCallable('getRecipe')(params);
const { data } = await getRecipe({ url });
```

## Implementation

### IndexedDB
I decided to use the recipe url being fetched as the `key`, and the stringified json response as the `value` in my IndexedDB store. Because it was a simple key-value pairing, I opted to use the library `idb-keyval`, which is a very simple, promise-based wrapper around the IndexedDB api. Highly recommend this library – it was super easy to set up and use.

This wasn't super necessary, but I created a small wrapper around `idb-keyval` with some error handling:

```js
// indexdb.ts

import * as idb from 'idb-keyval';

export const savePostRequest = async (body: string, key: string) => {
  try {
    await idb.set(key, body);
  } catch (err) {
    // something went wrong. Send it to Sentry/bugsnag/whatever
  }
};

export const getPostRequest = async (key: string): Promise<string> => {
  try {
    const value: string = await idb.get(key);
    return value;
  } catch (err) {
    // Key does not exist. No need to log error.
    return '';
  }
};
```

### Service worker integration
Now, we update the service worker file to use our cache api.

First, we need to [register a POST route with workbox](https://developers.google.com/web/tools/workbox/modules/workbox-routing#defining_a_route_for_non-get_requests). This tells our service worker to look for our route and intercept the requests:

```js
registerRoute(
  ({ url }) => url.href.endsWith('/getRecipe'),
  handlerCb,
  'POST',
);
```

If this was a GET request, instead of passing in our own `handlerCb`, we would be able to use a workbox pre-defined caching strategy as the second param to the `registerRoute` function, e.g. `new StaleWhileRevalidate()`. But it isn't, so we will have to write our own caching strategy. Workbox has [decent documentation on that](https://developers.google.com/web/tools/workbox/modules/workbox-routing#matching_and_handling_in_routes).

```js
const handlerCb = async ({ request }: RouteHandlerCallbackOptions) => {
  // Requests can only be consumed once, so we need to
  // clone the request before we can get the payload. The
  // payload (in this case, the url) will be the cache key
  const clonedRequest = (request as Request).clone();
  const requestBody = await clonedRequest.json();
  const key = requestBody.data.url;

  // Before making a request, check the cache. If our key
  // exists there, we don't need to make a request at all.
  // Ideally, there should be more logic here to call the
  // function anyway and check the diff, in case the
  // response returned from the function changes in the future
  const value = await getPostRequest(key);
  if (value) {
    return new Response(value);
  }

  // Our request was not found in the cache – call the 
  // function, cache it, and return it
  const response = await fetch(request);
  const responseBody = await response.text();
  savePostRequest(responseBody, key);
  return new Response(responseBody);
};
```


Putting it together, we have:

```js
// service-worker.ts

import { RouteHandlerCallbackOptions } from 'workbox-core';
import { registerRoute } from 'workbox-routing';
import { getPostRequest, savePostRequest } from './indexdb';

/** all the rest of the boilerplate service worker code **/

const handlerCb = async ({ request }: RouteHandlerCallbackOptions) => {
  const clonedRequest = (request as Request).clone();
  const requestBody = await clonedRequest.json();
  const key = requestBody.data.url;

  const value = await getPostRequest(key);
  if (value) {
    return new Response(value);
  }

  const response = await fetch(request);
  const responseBody = await response.text();
  savePostRequest(responseBody, key);
  return new Response(responseBody);
};

registerRoute(
  ({ url }) => url.href.endsWith('/getRecipe'),
  handlerCb,
  'POST',
);
```

And that's it! Like I said, there is more work to do here, such as not caching empty responses, checking for diffs between the cache and new responses, etc... But I wasn't able to find a full working solution to this problem anywhere so hopefully this is helpful to someone!