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!
Top comments (0)