Make Your Kirby-Based Website Work Offline

Originally published

by Johann Schopplich

A beautiful pattern to get you in the mood. 😊

You can improve your website’s performance easily by caching static assets, files, images, etc. and even make them available offline. This can be achieved with a service worker and expands the capabilities of any Kirby project.

Although this tutorial is tailored for the standard folder and panel setup of Kirby installations, it can be easily adapted for other websites and content management systems.

With a service worker you can progressively enhance your website. Progressive enhancement is a design philosophy (strategy) to deliver the essential content and functionality to as many users as possible. Further features (such as offline-first backing) are added on top as the end-users browser and/or internet connection allows.

🔗 I’m familiar, take me right to the main service worker

What Is a Service Worker?

A service worker is a script that your browser runs in the background. Using a service worker you can set a website up to use cached assets first, before getting more data from the network. Thus delivering an offline-first experience. This all works because a service worker runs independently from your browser and is able to intercept and handle network requests.

Working with service workers can be an extremely frustrating experience. When I first came across them, I didn’t understand the complex API and relied on many examples. Trys Mudford’s service worker and Google’s introduction to service workers helped me the most.

To make working with service workers easier, I prefer Workbox over the plain service worker API. Workbox is a collection of JavaScript libraries for service worker related functionalities. It provides a set of best practices and relieves you of writing a boilerplate for each of your websites.
I generally don’t like working with complex frameworks, but Workbox really is a boon — powerful and incredibly straightforward to use.

Let’s add some performance flavour to your website!

Create Your Kirby Service Worker

Service workers are typically located in the root directory. Because the finished script will be generated automatically with the Workbox CLI later, you may create your source service worker in another directory (or under another file name). I generally put all of my CSS and JavaScript source files into an src folder so I consequentially suggest creating an sw.js inside the src folder.

Open your freshly touched sw.js in your code editor. To access all of the Workbox modules you just need to add the following one-liner:

importScripts('https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js')

If you are reluctant to use Google’s CDN, you can download the Workbox scripts from GitHub and play them out via your website.

With an if statement we make sure that the Workbox namespace is registered:

if (workbox) {
  // Run Workbox directives in here
}

Preaching

Now we add files for our service worker to precache. Service workers can save a set of files to the cache when it’s being installed, ahead of the service worker being used. As a result, we can access those files later, independently of the network.

Most importantly, precaching gives us control over the cache so we can determine when to update assets. For example, after each code change or build run it ensures that the browser loads, saves and serves the latest (compiled) CSS and JavaScript files.

We let the Workbox CLI handle revision management and generation of the production-ready service worker. Just add (inside the if statement):

workbox.precaching.precacheAndRoute([])

The Workbox CLI will fill the empty array with a set of URLs, each with their own piece of revisioning information.

For that to work, create a manifest for the Workbox CLI called workbox-config.js in your project’s root directory:

module.exports = {
  "globDirectory": ".",
  "globPatterns": [
    // Precache CSS and JS files inside the assets folder
    "assets/**/*.min.{css,js}",
    // Precache Fonts
    "assets/fonts/**/*.{css,woff2}"
    // Optionally add other static assets, for example
    // Kirby's template specific script files
    // "assets/js/templates/*.js"
  ],
  "swDest": "sw.js",
  "swSrc": "src/sw.js"
}

Edit the globPattern object according to your file structure inside your assets folder.

If you use a custom folder structure for your Kirby installation, you might want to adapt swDest and swSrc to fit your setup.

Great! Now let’s generate the actual production-ready service worker using the Workbox CLI. Install it globally or for your current project:

npm install workbox-cli

I presume you have a build chain established inside your package.json already. Wonderful, so add workbox injectManifest workbox-config.js to your main build step.

Example:

...
"scripts": {
  "build": "... && workbox injectManifest workbox-config.js"
},
...

Now run build your assets and voilà! Your sw.js is fabricated.

But hold on, we still have to register it before the browser actually runs it.

Register the Service Worker

You can register your service worker by adding a simple script in your page’s footer just before the closing body tag. You probably have a footer.php snippet to put the following lines into:

<script>
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
  })
}
</script>

Well done! 🙌

Your main assets are precached now.

Extending Your Service Worker with More Caching Capabilities

Workbox provides common caching strategies to determine how to respond after receiving a fetch event:

  • Stale-while-revalidate
  • Cache first (cache falling back to network)
  • Network first (network falling back to cache)
  • Network only
  • Cache only

I recommend their excellent and illustrated documentation, as I won’t go into detail here to keep this tutorial as simple as possible.

Cache Images

You probably want to cache all images and serve them directly from the cache before bothering the network.

To do so, register a new route for your preferred image file extensions to be cached:

workbox.routing.registerRoute(
  // Common image types
  new RegExp('\.(?:png|jpg|svg|webp)$'),
  // Use a cache-first strategy
  new workbox.strategies.CacheFirst({
    cacheName: 'workbox-images-cache',
    plugins: [
      new workbox.expiration.Plugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days
        // Remove cached images before purging other caches
        purgeOnQuotaError: true
      })
    ]
  })
)

Cache Every Page

Now it’s getting interesting. 👌

Because relevant static assets are precached already and images are cached as well, mostly pages should remain (plain HTML). If your content is updated frequently I suggest the network first strategy. Thus Workbox will try to fetch the latest request from the network. If the request is successful, it’ll put the response in the cache. If not, the cached response will be served.

Caution: This following route caches everything following a filter. If you use a basic website, say a blog, then everything will be fine. For more dynamic Kirby websites I recommend to audit this script in-depth, before using it in production. Otherwise, responses or dynamic pages are also cached which may not be intended.

First, we create two arrays, ALLOWED_HOSTS and DISALLOWED_FRAGMENTS.

The first array is quite manageable. It lists every host from which resources may be cached.

const ALLOWED_HOSTS = [
  // Allow URLs from your domain
  location.host
  // Optionally add further domains, e.g. for cloud-optimized images
  // 'cloudinary.com'
]

The second array enumerates fragments that, if found in a URL, blocks the caching of a resource. In DISALLOWED_FRAGMENTS you have to insert all strings that occur in a URL or page that you don’t want to cache.

If you customised the slug of the panel, make sure to add it to the configuration, too.

const DISALLOWED_FRAGMENTS = [
  // Kirby’s API
  '/api',
  // Page drafts
  '?token',
  // The panel itself
  '/panel'
]

With a custom callback, we counter-check whether the current URL to be cached comes from an allowed host and doesn’t contain any disallowed fragments.

const matchCallback = ({ url, event }) => {
  return
    // Ignore non-GET requests
    event.request.method === 'GET'
    // Continue if the URL's host is allowed to be cached
    && ALLOWED_HOSTS.includes(url.host)
    // Cancel if any disallowed fragments exist
    && DISALLOWED_FRAGMENTS.some(el => url.pathname.includes(el)) === false
}

Finally, let’s register a new route. Of course, you can adapt the caching strategy to your needs.

workbox.routing.registerRoute(
  // Custom callback for handling a route
  matchCallback,
  // Use a network-first strategy
  new workbox.strategies.NetworkFirst({
    cacheName: 'workbox-pages-cache',
    plugins: [
      new workbox.expiration.Plugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60 // 30 Days
      })
    ]
  })
)

Note: You may wonder what happens if multiple registered routes match a URL. Workbox always proceeds according to the first defined strategy. Any additional matching route has no effect. Pretty nice!

Putting It All Together

You can download the full sw.js from from this Gist.

Things to Keep in Mind When Using Service Workers

(I will extend this section if anything else comes to my mind.)

Reloading the Page Won’t Update the Service Worker

A service worker doesn’t behave like a “normal” script. If you make a change to your service worker, then reloading the page won’t kill the old one and activate the new one. Solely closing the tab and reopening it actually loads an updated version of your service worker.

Chrome has a handy feature: Navigate to the Application tab in DevTools, open the Service Workers section and check “Update on reload”. Now your service worker is kept up to date with every page reload.

Closing Remarks

Service workers are like your favourite dish. Once you’ve bitten it, you don’t want to miss it anymore. Happy coding! 👌


No comment sections for now.

Feel free to contact me if you want to talk about this article. If you spot a typo or have improvements, I’d appreciate if you let me know. Thank you!