Enjoyable WebWorkers in Angular

With Comlink — created by Chrome Team

Photo by rawpixel on Unsplash

WebWorkers have attracted the attention as one of the most important things of web development in the near future. But today, it still has some problems. Especially, developer experience is not good. How to separate code, how to communicate with it, and how to deploy it… it’s not fun.

Comlink is a JavaScript library created by Google Chrome team to make WebWorkers enjoyable. It provides an easy way to communicate with classes defined in Worker-side.

This post explains how to integrate Comlink with your Angular application that created by Angular CLI. After read it, you will be able to create WebWorker script easily and separate application codes into that. Let’s get started!

🚨️Caveat🚨

All contents of this article are completely just for experiment. I don’t recommend you to adopt these in production. Enjoy!

Setting up an application

An example application is simple. Install Angular CLI if you still have it.

After installation, run the command to create a new application with minimal configuration. A minimal application doesn’t has files for testing. And just hit Enter key twice. No routing, and using CSS.

$ ng new angular-comlink-example --minimal
? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? CSS

Setting up worker-plugin

Then, install WorkerPlugin, which is a webpack plugin to split code for workers.

$ yarn add --dev worker-plugin

And install ngx-build-plus to use WorkerPlugin in Angular CLI’s build process. It allows us extend CLI’s webpack configuration without ejection!

$ ng add ngx-build-plus

To enable WorkerPlugin, create new webpack.extra.js file at the project root dir.

// webpack.extra.js
const WorkerPlugin = require('worker-plugin');
module.exports = {
plugins: [
new WorkerPlugin({
plugins: ['AngularCompilerPlugin']
})
]
};

At last, update the build task to load the extra configuration. There are two ways. One is via command line argument; --extraWebpackConfig .

npm scripts

Another option is via angular.json setting; extraWebpackConfig .

That’s all! Now, your Angular CLI project can build WebWorker script with code splitting. Let’s go next step.

Use Comlink

Install Comlink as the below. Comlink has its own TypeScript definition in the package, so you don’t need additional installation.

$ yarn add comlinkjs

Example: Markdown processor

For example, let’s make a markdown processor with Comlink. It can convert markdown text to HTML.

At first, create a class file named markdown.ts in src/app/worker directory. To create it, ng generate command is useful. This file will be the entry point of the worker’s bundle.

$ ng generate class worker/markdown

To handle markdown, please install marked and @types/marked.

yarn add marked && yarn add --dev @types/marked

Then, implement markdown.ts as following. It’s simple code to compile markdown to HTML with marked.

import * as marked from 'marked';
export class Markdown {
compile(source: string) {
return new Promise<string>((resolve, reject) => {
marked(source, (err, result) => {
if (err) {
reject(err);
return;
}
resolve(result);
});
});
}
}

To expose the class to outside the worker, call expose function as the below.

import * as marked from 'marked';
import { expose } from 'comlinkjs';
export class Markdown {
compile(source: string) {
return new Promise<string>((resolve, reject) => {
marked(source, (err, result) => {
if (err) {
reject(err);
return;
}
resolve(result);
});
});
}
}
expose(Markdown, self);

That’s all to implement Worker-side code. Just declare a class and expose it. Ain’t easy?

Call the class through comlink’s proxy

Next step. Let’s call the Markdown class through Comlink. It’s not static reference, but you can take all benefits of TypeScript and WebWorker.

At first, create MarkdownService as Angular service to separate business logic from component. Execute following command;

$ ng generate service service/markdown

And here is an initial implementation.

import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class MarkdownService {
async compile(source: string): Promise<string> {}
}

To use worker/markdown.ts , import Comlink and create a proxy instance with proxy function as the below. import('../worker/markdown).Markdown may look tricky. This import is not ES Module import but TypeScript’s feature that import only type definition without any JavaScript references. So, this static link will be eliminated in TypeScript compilation process and it can be separated to different bundles. And One more tricky TypeScript code; (Worker as any) . It is necessary to use the 2nd argument of new Worker() .

import { proxy } from 'comlinkjs';
const MarkdownWorker = proxy<
typeof import('../worker/markdown').Markdown
>(
new (Worker as any)('../worker/markdown', { type: 'module' })
);

The returned MarkdownWorker is a class constructor. To use its functionality, create its instance by new MarkdownWorker() . But it doesn’t return the same instance of Markdown but its promisified version. Look at the code. The construction returns Promise of the instance, so you need to await it. This is the Comlink’s interface. All from worker-side are promisified.

export class MarkdownService {
async compile(source: string): Promise<string> {
const worker = await new MarkdownWorker();
return worker.compile(source);
}
}

If you use a TypeScript-friendly editor, you can find out its autocompletion can suggest worker.compile with the correct type definition. Comlink is TypeScript friendly. That is why I’m loving this.

compile() has its correct type!

Run the app!

To see the work of Comlink and WorkerPlugin, finish the application. Here is AppComponent modified to use MarkdownService and show the result.

import { MarkdownService } from './service/markdown.service';
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<button (click)="compileMarkdown()">compile</button>
    <div>{{ result }}</div>
`
})
export class AppComponent {
result: string = '';
  constructor(private markdown: MarkdownService) {}
  async compileMarkdown() {
this.result = await this.markdown.compile(`## Hello Comlink`);
}

}

Let’s serve the app by usingng serve and open browser’s devtool. 0.worker.js is a chunk that is split by WorkerPlugin. It is loaded lazily after initial scripts are all loaded. It doesn’t block the initial rendering. Cool!

Running

You can load the worker script any time. If you call proxy in the compile method, the script will be loaded on-demand. Here is an example.

export class MarkdownService {
async compile(source: string): Promise<string> {
// proxy in method
const MarkdownWorker = proxy<typeof import('../worker/markdown').Markdown>(
new (Worker as any)('../worker/markdown', { type: 'module' })
);
const worker = await new MarkdownWorker();
return worker.compile(source);
}
}
Load script on-demand

Summary

  • Setting up WorkerPlugin with ngx-build-plus
  • A single class can be a worker with Comlink
  • TypeScript + Comlink is cool!!

All sample code is public in GitHub.

Any feedback is welcome. Thanks for reading!!