Caching firebase callable function requests with a service worker
Jess Solka
Posted on November 27, 2020
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 });
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 '';
}
};
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',
);
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);
};
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',
);
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!
Posted on November 27, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.