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, 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.
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 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.
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:
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().
],
})
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:
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.
Top comments (10)
I think it's important to note that this only works when running
vite build
and does not work with the dev server when runningvite
.I settled for a different approach which doesn't require
vite-plugin-pwa
and usestsc --watch
to run in parallel withvite
.I put the
sw.ts
file in thesw
dir to keep config separated betweentsconfig.sw.json
andtsconfig.json
files.This works a treat and will watch and emit the
sw/sw.ts
topublic\sw.js
for Vite dev server to use as a static asset.Just run
yarn dev
package.json
tsconfig.json
tsconfig.sw.json
It's worth also adding
"isolatedModules": false
- otherwise you'll get warnings in VScode asking you to addexport
to your scripts, and then Chrome/Edge etc will complain with"Unexpected token 'export'"
.This obviously won't trigger any sort of reload for the SW when the SW code changes, right?
You saved me a lot of time ❤️
As of 2020 Workbox has been relatively easy to import and use programatically. As you have a custom sw, I dont see the benefit of depending on vite-pwa.
I like the plugin approach, unless you are using a monorepo like lerna already, in which case a vite library probably makes more sense.
Part of the reason service workers are so relegated to obscurity is, they are treated like cookies and disabled on a whim, even if you dont store anything. Im planing to move custom sw logic to a worker, as Fetch in workers support is now wide spread. The same ideas and difficulties you shared still apply.
I agree, after playing more extensively with service workers I realized most logic like fetch interception and storage in indexed db should better be located in the web application or a worker itself - thanks for pointing that out once more.
But some things can only be done in a service worker, like push notification handling, for which the article would still be relevant.
This post seems to contradict itself:
"most logic... should better be located in the web application or a worker itself"
"But some things can only be done in a service worker"
Do you think most logic should be in a service worker or not in a service worker?
do you know you can use
injectManifest
strategy onvite-plugin-pwa
? The plugin will compile your TypeScript service worker.With latest version (0.11.13) you can also enable it on development, the pwa plugin will allow register the service worker with type module: vite-plugin-pwa.netlify.app/guide/...
Just check this example: github.com/antfu/vite-plugin-pwa/t... (we have also the same service workers working on react, preact, svelte and solid projects, check the examples directory)
Did not know about it, but solves the same problem.
Interestingly when using the plugin with
injectManifest
, it uses very similar code to mine. Had a great time figuring this out.rollup-plugin-typescript
is deprecated.@rollup/plugin-typescript
should be used.