DEV Community

loading...

Caching firebase callable function requests with a service worker

jesssolka profile image Jess Solka ・3 min read

Recently, I had an idea for a quick project - 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. 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 (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 use the ServiceWorker Cache API, which only supports GET requests.

Solution

Write a custom caching strategy using 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:

const getRecipe = (params: { url: string }) =>
  firebase.functions().httpsCallable('getRecipe')(params);
const { data } = await getRecipe({ url });
Enter fullscreen mode Exit fullscreen mode

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:

// 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 '';
  }
};
Enter fullscreen mode Exit fullscreen mode

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. This tells our service worker to look for our route and intercept the requests:

registerRoute(
  ({ url }) => url.href.endsWith('/getRecipe'),
  handlerCb,
  'POST',
);
Enter fullscreen mode Exit fullscreen mode

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.

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);
};
Enter fullscreen mode Exit fullscreen mode

Putting it together, we have:

// 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',
);
Enter fullscreen mode Exit fullscreen mode

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!

Discussion

pic
Editor guide