Progressive Web Apps: Working with Workers

1. Welcome

In this lab, you'll take an existing web application add add web worker to share state between two open windows. This is the eighth in a series of companion codelabs for the Progressive Web App workshop. The previous codelab was Service Worker Includes. This is the final codelab in the series.

What you'll learn

  • Add a shared worker between multiple open windows
  • Use Comlink to make working with workers easier

What you should know

  • JavaScript

What you will need

2. Get Set Up

Start by either cloning or downloading the starter code needed to complete this codelab:

If you clone the repo, make sure you're on the pwa06--working-with-workers branch. The zip file contains the code for that branch, too.

This codebase requires Node.js 14 or higher. Once you have the code available, run npm ci from the command line in the code's folder in order to install all of the dependencies you'll need. Then, run npm start to start the development server for the codelab.

The source code's README.md file provides an explanation for all distributed files. In addition, the following are the key existing files you'll be working with throughout this codelab:

Key Files

  • js/preview.js - Preview page JavaScript file
  • js/main.js - Main application JavaScript file

3. Write a Worker

Currently, your web app's preview functionality only shows the latest content on load. Ideally, it'd show a live preview as the user's typing. This requires compiling potentially large amounts of data and ferrying it between two different open windows. Because of this, it's not something we want to be doing on the main thread of any of the open windows. Instead, let's use a shared web worker.

To start, create a file js/worker.js with the following code:

import { expose } from 'comlink';
import { marked } from 'marked';

class Compiler {
  state = {
    raw: '',
    compiled: '',
  };
  subscribers = [];

  async set(content) {
    this.state = {
      raw: content,
      compiled: marked(content),
    };

    await Promise.all(this.subscribers.map((s) => s(this.state)));
  }

  subscribe(cb) {
    this.subscribers.push(cb);
  }
}

const compiler = new Compiler();

onconnect = (e) => expose(compiler, e.ports[0]);

Explanation

This code sets up a class, called Compiler, that allows content to be set and allows subscriptions to be called once that content has been compiled. Because it's a shared worker, there should only be a single instance of this class used, so a new instance of Compiler is instantiated. Then, to make working with this class feel seamless from outside the worker, Comlink is used to expose the compiler instance, allowing us to use all of the methods on it as if it were declared in the code using it. Because this is a shared worker instead of a dedicated worker, it needs to be exposed to all connections.

4. Send Content to the Worker

With the worker created, we now need to send content into it. To do so, update js/main.js to do the following:

  • Import the named export wrap from comlink
  • Create a new module-typed Shared Worker called worker, set its type to module, and point to it using the new URL pattern (new URL('./worker.js', import.meta.url))
  • Create a compiler variable that wraps the worker.port
  • In the editor's update function (editor.onUpdate), after saving content to the database, wait for compiler.set to finish, passing in the content

Explanation

Wrapping a Comlink export allows things like exposed class methods to be used as if they weren't shared across a worker boundary, with an exception being that now everything is asynchronous. Because this is a shared worker instead of a dedicated worker, Comlink needs to wrap the worker's port instead of the worker itself. Now, whenever an update to the editor is made, the content will be sent into the worker to be worked on!

5. Update the Preview Page

The final step is to get the compiled content out of the shared worker into the preview! The setup to do so is largely the same, but because functions can't pass between worker boundary, a proxy for the function needs to be used instead. Comlink, again, is here to help. Update js/preview.js to do the following:

  • Import the named exports wrap and proxy from comlink
  • Create and wrap the shared worker as you did in js/main.js
  • Call the compiler's subscribe method with a proxy function that sets the incoming data's compiled property to the inner HTML of the preview area

Once done, open up the preview, start typing in the editor, and be amused and excited watching your markdown automagically compile and appear in real-time in the preview area, all without blocking either page's main thread!

6. Congratulations!

You've learned how to use a shared worker to share state between multiple PWA instances.