DEV Community

wtho
wtho

Posted on

Custom Service Worker Logic in Typescript on Vite

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'],
    }),
  ],
})
Enter fullscreen mode Exit fullscreen mode

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) => {
  // ...
})
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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().
  ],
})
Enter fullscreen mode Exit fullscreen mode

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().
  ],
})
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
abarke profile image
Alex Barker • Edited

I think it's important to note that this only works when running vite build and does not work with the dev server when running vite.

I settled for a different approach which doesn't require vite-plugin-pwa and uses tsc --watch to run in parallel with vite.

I put the sw.ts file in the sw dir to keep config separated between tsconfig.sw.json and tsconfig.json files.

This works a treat and will watch and emit the sw/sw.ts to public\sw.js for Vite dev server to use as a static asset.

Just run yarn dev

package.json

{
  "name": "vite",
  "version": "0.0.0",
  "scripts": {
    "dev": "run-p --print-label dev:*",
    "dev:vite": "vite",
    "dev:swrk": "tsc --watch --project tsconfig.sw.json",
    "build": "tsc && vite build && tsc --project tsconfig.sw.json",
    "serve": "vite preview"
  },
  "devDependencies": {
    "npm-run-all": "^4.1.5",
  }
}
Enter fullscreen mode Exit fullscreen mode

tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ESNext", "DOM", "WebWorker"],
    "moduleResolution": "Node",
    "strict": true,
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "noEmit": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
  },
  "include": ["src"],
}
Enter fullscreen mode Exit fullscreen mode

tsconfig.sw.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "lib": ["ESNext", "WebWorker"],
    "noEmit": false,
    "strict": false,
    "outDir": "public",
    "target": "ESNext",
  },
  "include": [
    "sw"
  ],
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
mikemaccana profile image

It's worth also adding "isolatedModules": false - otherwise you'll get warnings in VScode asking you to add export to your scripts, and then Chrome/Edge etc will complain with "Unexpected token 'export'".

// See comment at https://dev.to/wtho/custom-service-worker-logic-in-typescript-on-vite-4f27#comment-1n0i7

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "lib": ["ESNext", "WebWorker"],
    "noEmit": false,
    "strict": false,
    "outDir": "public",
    "target": "ESNext",
    // Makes script require 'export' and Chrome will fail to load extensions with
    // "Unexpected token 'export'"
    "isolatedModules": false
  },
  "include": ["service-worker/service-worker.ts"]
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
antonofthewoods profile image
Anton Melser

This obviously won't trigger any sort of reload for the SW when the SW code changes, right?

Collapse
 
_hariti profile image
abdellah ht • Edited

You saved me a lot of time ❤️

Collapse
 
fossprime profile image
Ray Foss

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.

Collapse
 
wtho profile image
wtho

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.

Collapse
 
mikemaccana profile image
Mike MacCana is on a Twit Haitus 😎

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?

Collapse
 
userquin profile image
userquin

do you know you can use injectManifest strategy on vite-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)

Collapse
 
wtho profile image
wtho

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.

Collapse
 
aliangliang profile image
Wei-Liang Liou

rollup-plugin-typescript is deprecated. @rollup/plugin-typescript should be used.