Custom Service Worker Logic in Typescript on Vite
Tags: webdev, vite, typescript, pwa
Published: 2022-01-07 14:27:04

## Adding a basic Service Worker

I recently had a tiny website project which I wanted to make available offline. This is achieved by adding a Service Worker. And thanks to projects like [workbox](https://developers.google.com/web/tools/workbox), getting basic functionality like caching for offline-use is fairly easy to set up.

As my project is powered by vite, I use [vite-plugin-pwa](https://vite-plugin-pwa.netlify.app/) to setup workbox with a few lines of code:

```ts
// vite.config.ts
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.svg'],
    }),
  ],
})
```

The Service Worker can now be tested running `vite build` and `vite preview`.

But I wanted more. I wanted to intercept specific `fetch` requests the website runs to obtain rendered data, which is also a feature the service worker provides, or you might want to handle `push` notifications on your own.

## Writing custom Service Worker Logic in Typescript

I love Typescript. It checks your code at compile-time or even already at write-time and saves you many basic test cases. So let's use it when writing Service Worker code. But to get there, we face several challenges:
* Service Worker Typings
* Separate Compilation

My custom Service Worker is a file called `src/sw-custom.ts`. I setup the typings by following some advice on [this GitHub issue](https://github.com/microsoft/TypeScript/issues/11781#issuecomment-872329281) and ended up using this:
```ts
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
const sw = self as unknown as ServiceWorkerGlobalScope & typeof globalThis;

sw.addEventListener('install', (event) => {
  // ...
})
```
Which means we have to use `sw` instead of `self`. Depending on the Typescript version, you might have to adjust the typings. Make sure you check out the aforementioned GitHub issue.

Now we are ready to compile the file. But this brings us to the second problem: Vite assumes the project is either a (multi-)webpage project with an `html` entry file(s), OR a library with a javascript entry file (library mode). In our case, we need both: The base website with `index.html` and the Service Worker as bundled Javascript.

We have to introduce a new process to do the bundling independently. We could setup a new vite/rollup/webpack project to do the Service Worker bundling separately, but I prefer to keep all as a single project, and there is a simpler approach.

## Do the Service Worker Transpilation & Bundling as a Vite Plugin

Transpiling and bundling Typescript code as a single Javascript file can easily done with rollup (which is internally used by Vite) using the Javascript API:
```ts
import { rollup, InputOptions, OutputOptions } from 'rollup'
import rollupPluginTypescript from 'rollup-plugin-typescript'
import { nodeResolve } from '@rollup/plugin-node-resolve'

const inputOptions: InputOptions = {
  input: 'src/sw-custom.ts',
  plugins: [rollupPluginTypescript(), nodeResolve()],
}
const outputOptions: OutputOptions = {
  file: 'dist/sw-custom.js',
  format: 'es',
}
const bundle = await rollup(inputOptions)
await bundle.write(outputOptions)
await bundle.close()
```
Note that we require `plugin-node-resolve` here to include any imported library from other modules in the bundle of the service worker. If your custom Service Worker has no imports, you do not need this plugin.
After bundling, the custom service worker can be accessed as `/sw-custom.js` from the app.

This small, independent bundling program can be wrapped as a Vite plugin and then be used in the plugin array to run it on every Vite Build:
```ts
const CompileTsServiceWorker = () => ({
  name: 'compile-typescript-service-worker',
  async writeBundle(_options, _outputBundle) {
    const inputOptions: InputOptions = {
      input: 'src/sw-custom.ts',
      plugins: [rollupPluginTypescript(), nodeResolve()],
    }
    const outputOptions: OutputOptions = {
      file: 'dist/sw-custom.js',
      format: 'es',
    }
    const bundle = await rollup(inputOptions)
    await bundle.write(outputOptions)
    await bundle.close()
  }
})

export default defineConfig({
  plugins: [
    VitePWA(),
    CompileTsServiceWorker().
  ],
})
```

## Use our Service Worker

Now it is time to load our sw-custom code alongside the workbox service worker. There can only be one service worker entry, but we can tell workbox to import our custom script from this root file. `vite-plugin-pwa` exposes the option in the plugin through the `workbox.importScripts` option:
```ts
export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        importScripts: ['./sw-functional.js'],
        globIgnores: ['**/node_modules/**/*', '**/sw-custom.js'],
      },
    }),
    CompileTypescriptServiceWorker().
  ],
})
```

Bundling the Service Worker each time is no big pain for me, as the service worker code is small.

To make things easier when working on `sw-custom.ts`, I run nodemon to get an automatic reload:
```
npx nodemon --exec 'npx vite build && npx vite preview' -w src -w vite.config.ts -e 'ts,js'
```

Let's wrap things up for now. I am sure there can be optimizations, but it is a start. Leave a comment if you have suggestions.