DEV Community

loading...

Writing Service Workers in ReScript

webbureaucrat profile image webbureaucrat Originally published at webbureaucrat.gitlab.io on ・7 min read

Does your SPA work fully offline? Would you like to define a caching strategy in an exhaustively type-safe way? If so, you might be interested in this Service Worker binding for ReScript (formerly BuckleScript / ReasonML). This article documents the binding by example, including two different caching strategies and a service worker registration example.

The need for Service Workers in ReScript

JavaScript Service Workers are a way of intercepting HTTP requests in a web app and caching the result. This allows web app developers to write applications that work fully offline or supplement poor network speeds. They're extremely valuable in applications that have a lot of large assets such as SPAs (single page applications).

However, with great power comes great responsibility. The code that manages all HTTP requests can't afford to have any bugs, but JavaScript is so dynamic and so weakly typed that it's very easy to inadvertently ship typos.

Enter ReScript, which is statically typed and more functional than TypeScript. It's much more difficult to introduce bugs in such a robust type system, which is why I use ReScript for all my Service Workers.

Setting up your new service worker project

For a couple of reasons, it's almost entirely necessary to use a script bundler instead of ES6 modules. (It can be done with ES6 modules if you're able to mess with the security headers and trim the export statement off the bottom of the compiled script, but that's pretty cursed.) If you, (like me) don't usually do this, I wrote a quick example ofsetting up webpack in ReScriptin a previous article.

In addition to a script bundler, install the bs-fetch and rescript-service-workernpm packages.

Defining the service worker cache strategies

For completeness, this tutorial will include both a cache-first and a network-first component. This should allow the reader to easily adapt the sample code to other strategies.

My strategy for this site will be as follows:

  • If the asset is in the asset cache:
    • resolve with the cache version
    • update the cache version in background
  • Else (the asset is not in the cache)
    • If the network can be reached
    • resolve with the network version
    • update the runtime cache in background with the network version
    • Else (the network cannot be reached)
    • resolve with the runtime cache version

Basically, I have some assets like fonts and stylesheets which I don't think will change very much very often, so I want to use a cache-first strategy for them while keeping them up to date in background. However, I have content pages that should be as recent as possible while being able to fail over to a cache.

Bindings and configuration

Personally, it's my practice to keep it to two or three open statements per ReScript file. In this case, because Service Workers are so tied to JavaScript Promises, it makes sense to open Js;.

I'm also going to open ServiceWorker because there are so many different modules from that package I will need. I would also highly recommend opening ServiceWorkerGlobalScope, which, as the name suggests, exposes bindings to native JavaScript functions and values that are exposed by default in native JavaScript in the Service Worker context.

Outside of the rescript-service-worker bindings and the default Js bindings, there's only one more I'm going to add, which is the binding to the JavaScript error constructor

@bs.new external makeExn: string => exn = "Error";
Enter fullscreen mode Exit fullscreen mode

This will be used in a Promise constructor (basically to make the types work out).

Configuration

The next few lines represent some basic configuration for the app. There are two caches, which we will name "static" and "runtime" and we will version them in order to more quickly pick up changes when we need to.

/* configuration */
let version = "0.0.10"; 

let assetCacheName = Js.String.concat(version, "webbureaucrat-static-");

let runtimeCacheName = Js.String.concat(version, "webbureaucrat-runtime-");
Enter fullscreen mode Exit fullscreen mode

Adding requests to the static asset cache

This is a tricky binding, and I want to call special attention to it. In service workers, a cache is basically a key/value store, where a key can be either a URL string or a JavaScript Request object. Therefore, to properly bind to Cache.addAllwe would need an array that can hold string or a Request or any combination thereof.

I went to StackOverflow with this problem, and you can see the solution there.

let assets = [
  Cache.str("/css/default.css"),
  Cache.str("/css/fonts/DejaVuSansMono-webfont.woff"),
  Cache.str("/css/menu.css"),
  Cache.str("/css/prism-base16-monokai.dark.css"),
  Cache.str("/css/site.css"),
  Cache.str("/css/util.css"),
  Cache.str("/img/icons/192.png"),
  Cache.str("/manifest.json"),
  Cache.str("/portfolio/"),
  Cache.str("/resume/"),
  Cache.str("/about/")
];

let precache = (): Promise.t<unit> =>
  caches(self)
  -> CacheStorage.open_(assetCacheName)
  |> Promise.then_(cache => cache -> Cache.addAll(assets))
;

self -> set_oninstall(event => {
  Js.log("The service worker is being installed.");
  event -> Notifications.ExtendableEvent.waitUntil(precache());
});

Enter fullscreen mode Exit fullscreen mode

What's going on here is a Cache.addAll takes an array of Cache.req, a type that can be instantiated either via Cache.request or Cache.string. Here, since I'm only using string-representations, it makes sense to use Cache.str for all.

Including helper methods for interacting with the service worker caches

Service workers are a relatively low-level API, so it's helpful to break our service worker cache strategy into building blocks that can be reused, so if we change our cache strategy later on, we will have to change very little actual code.

The fromCache function abstracts over fetching a response from a given cache, failing if it can't find the resource in cache. fromNetwork just passes straight through to the bs-fetch binding (for now), and addToCache, well, adds a request to a given cache.

let fromCache = (req: Fetch.Request.t, cacheName: string):
  Promise.t<Fetch.Response.t> =>
  caches(self) -> CacheStorage.open_(cacheName)
|> Promise.then_(cache => cache -> Cache.Match.withoutOptions(req))
|> Promise.then_(matching => switch(Js.Nullable.toOption(matching)) {
    | Some(m) => Promise.resolve(m);
    | None => Promise.reject(makeExn("no-match"));
  })
;

let fromNetwork = (req:Fetch.Request.t):
  Promise.t<Fetch.Response.t> =>
  Fetch.fetchWithRequest(req)

let addToCache = (req: Fetch.Request.t, cacheName: string):
  Promise.t<unit> => {
  caches(self)
    -> CacheStorage.open_(cacheName)
  |> Promise.then_(cache => cache -> Cache.add(#Request(req)))
};

Enter fullscreen mode Exit fullscreen mode

These are small functions, but they'll make our lives a little easier in the next step.

Defining both cache strategies

Now we can more easily define our two strategies we described at the top. The first, assetStrategy, takes grabs the asset from the cache and then updates it if it's in the asset cache.

The second tries the network first and then fails over to the runtime cache if there's a problem with the network.

let assetStrategy = (req: Fetch.Request.t, cacheName: string):
  Promise.t<Fetch.Response.t> => {
  let result = req -> Fetch.Request.makeWithRequest
    -> fromCache(cacheName);

  /* update cache iff. item already exists in cache */
  let _ = result |> Promise.then_(_ => addToCache(req, cacheName));

  /* don't wait on the update to return */
  result
};

let runtimeStrategy = (req: Fetch.Request.t, cacheName: string):
  Promise.t<Fetch.Response.t> => {
  let result = req -> Fetch.Request.makeWithRequest -> fromNetwork
  |> Promise.catch(_ => req -> Fetch.Request.makeWithRequest
                   -> fromCache(cacheName));

  let _ = addToCache(req, cacheName);

  result
};

Enter fullscreen mode Exit fullscreen mode

A word on Fetch.makeWithRequest

This is the binding to the constructor that makes a copy of the givenRequest, and you'll notice I use it quite a bit. This is becauseRequests are stateful, which can very easily introduce some very weird and counterintuitive bugs. Therefore, I always make it a habit of copying a Request every time I reference it. Even if it's not always necessary, it means I can change my code without worrying whether I've introduced a state-management bug.

onfetch

Because our code is well-organized, we can write our onfetch event handler in just three lines. Our response, per our requirements, tries the asset cache strategy and then fails over to the runtime cache strategy, using the versioned cache names we defined at the top of the file.

self -> set_onfetch(event => {
  let req = event -> FetchEvent.request;
  let resp: Promise.t<Fetch.Response.t> =
  req -> Fetch.Request.makeWithRequest
    -> assetStrategy(assetCacheName)
  |> Promise.catch(_ => req
                   -> Fetch.Request.makeWithRequest
                   -> runtimeStrategy(runtimeCacheName));
  event -> FetchEvent.respondWith(#Promise(resp));
});

Enter fullscreen mode Exit fullscreen mode

Note: Service Workers themselves are scoped to their directory, so you'll probably want to locate your output at the root of your project. Unfortunately, there's no easy way to do this with ES6 modules in ReScript. Configuring a script bundler is out of scope for this article, but I have written atutorial for configuring webpack for ReScript, and, as always, feel free to reach out or open an issue if you have trouble.

Write the Service Worker loader

Lastly, if you're writing a Service Worker, you will need to run a script to register it, which is trivial.

Here's my solution, in a file called SWLoader.res:

open ServiceWorker;
open ServiceWorkerGlobalScope;

let path = "/SW.bs.js";

let _ = self -> navigator
  -> ServiceWorkerNavigator.serviceWorker
  -> ServiceWorkerContainer.Register.withoutOptions(path)
  ;

Enter fullscreen mode Exit fullscreen mode

This assumes that my build outputs a file called /SW.bs.js in the root of the directory. Configure your own path appropriately.

Source / For Further Reading

This has been a tutorial on service workers in ReScript, based on this website's own service worker. If anything is unclear, feel free to view the source in context on GitLab. You'll notice that my main assets like scripts and stylesheets are automatically available offline, and you'll find every article you visit on my site is available in the runtime cache. If something is still unclear, feel free to reach out or open an issue, and I'll be happy to take another run at it.

Discussion (0)

pic
Editor guide