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-worker
npm 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 open
ing 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";
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-");
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.addAll
we 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());
});
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)))
};
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
};
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 becauseRequest
s 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));
});
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)
;
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.
Top comments (0)