How Obvibase uses Web Workers

Posted on June 30, 2020 on Obvibase under the hood by Ivan Novikov.

Obvibase client is written in TypeScript, and it's made up of three categories of TypeScript files/modules:

  • files intended to be run only in the web worker, marked with names ending with .worker.ts

  • files intended only for the UI ("main") thread, marked with names ending with .ui.ts or .ui.tsx

  • all other files that can be used in either one or both threads, such as libraries, utilities, and abstract type definitions.

The build process automatically offloads the .worker files to a web worker, so instead of having a single worker script, you can put the code that each UI component needs to run on the web worker into a component-specific .worker file that lives right next to the component's .ui file in the project structure, much like you can co-locate queries and components in GraphQL.

Here's how it works. The entry module that's inserted into a page and imports everything else is a .ui file like index.ui.ts. This file can directly or indirectly (via other .ui files) import .worker files, and this type of imports gets special treatment during the build. We detect the type of the imported object using Typescript compiler, make sure that the type satisfies certain criteria which I'll describe in a second, and feed the importing .ui file a fake object that has the same signature as the original object, but internally delegates everything to the web worker via remote procedure call.

For example, imagine we have a UI component that needs to post some logged data to the server, and we want that HTTP request to be made on the worker thread. We create component.worker.ts exporting a void function postData that makes the request, and we create a file component.ui.ts that imports postData and calls it. When building the UI script, component.worker.ts gets replaced with a module that exports postData implemented as a function that sends a message to the web worker telling it to call a certain function with certain parameters, whereas the original component.worker.ts gets included in the worker script.

There are the following rules:

  • Anything that a .ui file exports can only be imported by another .ui file.

  • Anything that a .worker file exports can only be imported by a .worker file or a .ui file.

  • When a .ui file imports something from a .worker file, this something must be one of the following:

    • A void function: (...args: any[]) => void

    • A promise: Promise<any>

    • A function returning a promise: (...args: any[]) => Promise<any>

    • An observable: Observable<any>

    • A function returning an observable: (...args: any[]) => Observable<any>

Notice that a .ui file can import from a .worker file, but not the other way around. The reason is that we want a client-server model with the web worker as the server, because in future we may want to replace the web worker with a shared web worker or a service worker, and in either case, we'd have one worker serving multiple tabs from the same origin, just like in an Electron app the main process serves multiple renderer processes. Take the simple example of a void function: if the worker could remotely call such function on the UI thread, we wouldn't know which of the potentially multiple UI threads should execute it.

Why have the .ui files - isn't marking some files as .worker files enough to tell what needs to be offloaded to the worker? Strictly speaking it is enough, and I went this route initially, but it leads to bad developer experience. Imagine something is imported from a.worker.ts to b.ts, then from b.ts to c.worker.ts. This file structure is a no-go: the import from a.worker.ts to b.ts is of the kind where we use remote procedure call, so b.ts must run on the UI thread, and we cannot import from the UI thread (b.ts) to the worker thread (c.worker.ts) as just discussed. But which of the two imports involved is erroneous? - there is no immediate way to tell. With the notion of .ui files, there is: as per above rules, b.ts is not allowed to import from a.worker.ts, and if it was b.ui.ts instead of b.ts, c.worker.ts would not be allowed to import from b.ui.ts.

Lastly, there are two limitations of this architecture that I wanted to mention:

  • Where it says <any> in the above list of what you can import from .worker to .ui, that <any> must be serializable with structured clone so that it can be passed between the worker and the UI thread, and I haven't figured out a way to enforce this additional rule with Typescript.

  • In the example of postData being imported from component.worker.ts to component.ui.ts, that postData should ideally be internal to the component and not accessible in other places in the project, but I don't see a way to achieve this.