platform-webworker in Angular

Angular provides APIs that allow developers to write code that's decoupled from the underlying platform; ie. The DOM and web APIs provided by the browser.

This allows you to write code which could run on any number of platforms. eg. You can write your application as an SPA for the browser, and then run it separately on a NodeJS server to generate static HTML for SEO.

The well known built-in platforms are platform-browser and platform-server. But there's another experimental platform that's been mostly forgotten about: platform-webworker

What is "platform-webworker"?

In a nutshell: platform-webworker runs all (read: most of) your Angular code inside a Worker thread, which then communicates with the UI thread solely to render your web page through the DOM.

The idea is if you've successfully isolated yourself from using the DOM, why run any of your code in the same thread?

platform-webworker creates two pieces of your application: UI and Worker. The UI piece will get bootstrapped on the DOM thread like normal, then it will spin up a new Worker with your application code.

This should produce a smoother UI, free of "jank", as most of your application's computations will occur separate from the UI.

Building for platform-webworker

Unfortunately there's currently no Angular CLI support for building platform-webworker apps.
Thankfully Enrique oriol has done the hard work and written up instructions on how to build it: Angular with Web Workers: Step by Step

Note: These instructions won't work with Angular CLI v6 because ng eject is currently disabled. Once it's reenabled, I will make sure to update this post.
In the meantime, you can use Angular v6 with Angular CLI v1.7.4

I've also created a github repo setup to build platform-browser, platform-server, and platform-webworker altogether: github.com/rolaveric/angular-webworker-playground

How to work with the UI and DOM thread

Angular's template syntax and APIs let you do a lot without ever touching the DOM directly. But if you're writing your own UI widgets, chances are you will need direct DOM access. How does that work with platform-webworker?

Lets say I want to access document.activeElement. I want an ActiveElement service with a get() method which returns document.activeElement.

import { DOCUMENT } from '@angular/common';
import { Injectable, Inject } from '@angular/core';

@Injectable()
export class ActiveElement {
  constructor(@Inject(DOCUMENT) private document: any) {}

  get() {
    return this.document.activeElement;
  }
}

platform-webworker works in two pieces: UI and Worker. So we need our ActiveElement service to run on the UI, but be accessible from the Worker.

Step 1: Create a ServiceMessageBroker

A ServiceMessageBroker accepts RPC messages. To create one you'll need to inject ServiceMessageBrokerFactory, provided by platform-webworker, and call createMessageBroker(CHANNEL) where CHANNEL is a string that's shared by both the service on the UI and the client on the Worker.

import { ServiceMessageBrokerFactory, ServiceMessageBroker } from '@angular/platform-webworker';

@Injectable()
export class UiActiveElement {
  messageBroker: ServiceMessageBroker;
  constructor(
    @Inject(DOCUMENT) private document: any,
    smbf: ServiceMessageBrokerFactory
  ) {
    this.messageBroker = smbf.createMessageBroker('ACTIVE_ELEMENT_CHANNEL');
  }
}

Step 2: Expose methods with registerMethod()

To expose methods to RPC messages, we need to call ServiceMessageBroker.registerMethod(), and specify what type of objects we expect as the arguments and result.

Argument types need to come from the SerializerTypes enum, but the only two you need to worry about are PRIMITIVE and RENDER_STORE_OBJECT.
Primitives include anything that can be natively passed to Workers, which does not include DOM elements. Instead platform-webworker allows you to pass around references to DOM elements called Render Store Objects. When you pass one from the Worker to the UI, platform-webworker will map it to the DOM element it represents. And in reverse, if you pass an element back to the Worker, it will map it to a Render Store Object.

import { SerializerTypes } from '@angular/platform-webworker';

@Injectable()
export class UiActiveElement {
  start() {
    this.messageBroker.registerMethod(
      // Method name
      'get',
      // Argument types
      [],
      // Function to call
      this.get.bind(this),
      // Return type
      SerializerTypes.RENDER_STORE_OBJECT
    );
  }
}

We put our registerMethod() calls into a separate method which we're calling start(). There's nothing significant about the name start(); It could just as easily be init() or register(). It just needs to be a method that can be called by our next step-

Step 3: Create a PLATFORM_INITIALIZER provider

Normally when we run services in Angular, we do so from a component - either directly or indirectly. However, we don't have any components on the UI - they all live on the Worker. So how do we get our service to start listening for RPC messages?

Enter PLATFORM_INITIALIZER: A DI token which accepts multi: true providers for functions which will be called once all the platform services are ready. We can use this to call our start() method ASAP.

import { FactoryProvider, Injector, NgZone, PLATFORM_INITIALIZER } from '@angular/core';

// The factory gets injected very early, so we can only rely on Injector being available
function platformInitFnFactory(injector: Injector) {
  return () => {
    // By the time our function is called, NgZone should be available
    const zone: NgZone = injector.get(NgZone);
    zone.runGuarded(() => injector.get(UiActiveElement).start());
  };
}

// We register our factory as a `PLATFORM_INITIALIZER` multi provider
export const platformInitActiveElementProvider: FactoryProvider = {
  provide: PLATFORM_INITIALIZER,
  useFactory: platformInitFnFactory,
  multi: true,
  deps: [Injector]
};

Step 4: Pass UI providers to bootstrapWorkerUi()

If you followed the instructions from Enrique's blog post, you'll have a src/main.webworker-ui.ts file that calls bootstrapWorkerUi() from platform-webworker.

bootstrapWorkerUi() takes two arguments: The path to the Worker code bundle, and an array of DI providers. We want to pass in a provider for the UiActiveElement class, and our PLATFORM_INITIALIZER provider.

import { bootstrapWorkerUi, ServiceMessageBrokerFactory } from `@angular/platform-webworker`;
import { DOCUMENT } from '@angular/common';

import { UiActiveElement, platformInitActiveElementProvider } from './ui/active-element.service';

bootstrapWorkerUi(
  'webworker.bundle.js',
  [
    // All the providers must match the `StaticProvider` interface
    // That's why we can't just pass in `UiActiveElement` as is
    {provide: UiActiveElement, useClass: UiActiveElement, deps: [DOCUMENT, ServiceMessageBrokerFactory]},
    platformInitActiveElementProvider
  ]
)

Step 5: Create a ClientMessageBroker on the Worker

A ClientMessageBroker is the client equivalent of ServiceMessageBroker - making RPC messages to send to the service on the UI. To create one you'll need to inject ClientMessageBrokerFactory, provided by platform-webworker, and call createMessageBroker(CHANNEL) where CHANNEL is the same as from the UI service.

import { ClientMessageBrokerFactory, ClientMessageBroker } from '@angular/platform-webworker';

@Injectable()
export class WorkerActiveElement {
  messageBroker: ClientMessageBroker;
  constructor(
    cmbf: ClientMessageBrokerFactory
  ) {
    this.messageBroker = cmbf.createMessageBroker('ACTIVE_ELEMENT_CHANNEL');
  }
}

We can now call the methods on the UI service using runOnService(), which need a UiArguments object to wrap the method name and arguments.

import { UiArguments, SerializerTypes } from '@angular/platform-webworker';

@Injectable()
export class WorkerActiveElement {
  get(): Promise<any> {
    const uiArgs = new UiArguments('get', []);
    return this.messageBroker.runOnService(uiArgs, SerializerTypes.RENDER_STORE_OBJECT);
  }
}

Now our get() method will return a promise which resolves to an object that can be passed to things like renderer.addClass(nativeElement, className).

@Component({...})
export class MyComponent {
  constructor(
    renderer: Renderer2,
    activeElement: WorkerActiveElement
  ) {
    activeElement.get()
      .then((element) => {
        renderer.addClass(element, 'active');
      });
  }
}

Working with multiple platforms

The example above works fine if you're writing entirely for platform-webworker, but it won't work with platform-browser or platform-server. What you need is a common interface and platform specific implementations.

Our common interface will be a class with a get(): Observable<any> method, where any is actually something which can be passed to Renderer2.addClass() for that platform. eg. A DOM element on platform-browser and platform-server, and a Render Store Object on platform-webworker.

ServiceMessageBroker and ClientMessageBroker normally deal with promises, not observables. But if we're going to support multiple platforms, we're better off using observables for the common API. That way if a platform's implementation is totally synchronous, it doesn't need to wait till next tick.

export class ActiveElement {
  get(): Observable<any> {
    throw new Error('ActiveElement.get() No implementation available for this platform');
  }
}

We can now create a sub-class for platform-browser and platform-server to use.

export class BrowserActiveElement extends ActiveElement {
  constructor(@Inject(DOCUMENT) private document: any) {}

  get(): Observable<any> {
    return of(this.document.activeElement);
  }
}

// This provider will pass an instance of `BrowserActiveElement` whenever something requests `ActiveElement` via DI
export const browserActiveElementProvider = {
  provide: ActiveElement,
  useClass: BrowserActiveElement,
  deps: [DOCUMENT]
};

The good thing about this pattern is we can reuse the platform-browser service in the UI part of our platform-webworker code, so UiActiveElement only needs to worry about creating and wiring up the message broker.

import { ServiceMessageBrokerFactory, ServiceMessageBroker } from '@angular/platform-webworker';

@Injectable()
export class UiActiveElement {
  messageBroker: ServiceMessageBroker;
  constructor(
    private activeElement: ActiveElement,
    smbf: ServiceMessageBrokerFactory
  ) {
    this.messageBroker = smbf.createMessageBroker('ACTIVE_ELEMENT_CHANNEL');
  }

  start() {
    this.messageBroker.registerMethod(
      // Method name
      'get',
      // Argument types
      [],
      // Function to call
      () => from(this.activeElement.get()),
      // Return type
      SerializerTypes.RENDER_STORE_OBJECT
    );
  }
}

We can register providers for both BrowserActiveElement and UiActiveElement with bootstrapWorkerUi(), since they're not reusing the same DI token.

bootstrapWorkerUi(
  'webworker.bundle.js',
  [
    browserActiveElementProvider,
    {provide: UiActiveElement, useClass: UiActiveElement, deps: [ActiveElement, ServiceMessageBrokerFactory]},
    platformInitActiveElementProvider
  ]
)

Problems with platform-webworker

As you've no doubt picked up, there are some issues with platform-webworker. But I do believe they're all solvable:

  • No Angular CLI support
    The lack of Angular CLI support is a killer for anyone (like me) who has become dependant on it as a build tool, without needing to know the inner workings with Webpack.
    Solution: I'm hoping CLI v6's support for schematics will allow the community to provide a solution.

  • No access to $event.target
    A common use case for DOM access is to test what element an event was triggered on. eg. A dropdown should close when a click occurs outside it's menu.
    Unfortunately the event details which are passed across to the Worker don't include $event.target - even if you specify it as an input with @HostListener().
    Solution: The simplest solution would be for Angular to always resolve $event.target to a Render Store Object for any event. A better solution might be to only return specifically requested properties, and return them as Render Store Objects if they're HTML elements.
    But that would only work for @HostListener(), not for event bindings in templates (eg. <a (click)="doThing($event.target)">) unless the compiler was also updated.
    Till then, the only workaround is to setup a custom service on the UI that will listen to events for you.

  • Message Brokers could be easier to setup
    As you saw before, there's a lot of boilerplate involved in getting Message Brokers setup.
    Solution: The best pattern I've found is to write a service for platform-browser and then expose it's methods using Message Brokers. If you could simply generate those Message Brokers, either at compile time or using CLI generators, that would make things much easier.
    A good starting point would be an Angular CLI v6 schematic.

  • Message Brokers should return Observables
    Currently ServiceMessageBroker.registerMethod() expects a function that returns a Promise, and ClientMessageBroker.runOnService() returns a Promise.
    Trouble is, everything else you're doing with Angular is based around Observables. Sticking to Observables for everything keeps everyone on the same page.

  • UI Library support
    None of the UI widget library that I've tried have kept the DOM access sufficiently isolated, so I can't use them with platform-webworker without forking the code and modifying them.
    Solution: Simplest solution is to submit PRs to these libraries to be more platform-webworker friendly.
    What would also help are standard APIs for accessing DOM elements in the same ways these libraries are doing. Otherwise you'll end up with 3 different services that all do element.focus() or element.contains(targetElement).
    But till then, I don't think there's much harm in a little duplication.

  • Documentation!
    Unless you go hunting through the code, you may not even realise that Angular's platform-webworker exists. And the only way to understand how it works is to reverse engineer the existing code.
    Solution: More documentation! More blog posts!
    I'm hoping some people will read this, give it a try, and record their experiences with more online articles.

Conclusion

platform-webworker is a forgotten feature of Angular that mostly works, but it's lacking some tooling and ecosystem support. But with some promotion and TLC, I think it could be really useful.

I'm going to do what I can with the time permitted to me to promote and improve platform-webworker, and I'd appreciate any support being offered. Even reports on your own experiences (what's hard, what's impossible, etc.)

Cheers,
Jason Stone