47
Custom Service Worker Logic in Typescript on Vite
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, 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 to setup workbox with a few lines of code:
// 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.
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 and ended up using this:
/// <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.
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:
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:
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().
],
})
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:
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.
47