Using comlink with typescript and worker-loader

·

0 min read

worker-loader and comlink are two solution which make web-workers a joy to use. This short post summarizes how to make them play well with each other in a typescript codebase.

Comlink is a Google project that implements a proxy based RPC mechanism to invoke methods on objects present in web-workers.

Being proxy based, most of the times invocation is fairly transparent and the outcome is a lot easier to read than if we were using postMessage and MessagePort APIs directly. Internally of-course comlink use the same APIs under the hood.

Comlink's README already outlines the usage adequately and also David East has written a great introduction to Comlink here, so this post will mostly focus on usage with typescript.

Worker loader

worker-loader is a webpack plugin that makes it trivial to use webworkers with webpack. When you are using webpack, you'd usually want to have hash appended file paths for long term caching and worker-loader removes the need to refer to a manifest to derive the file path to be used for passing to Worker constructor.

With worker-loader installed, we can simply do:

import ExpensiveProcessor from "worker-loader!../core/expensive-processor.worker";

And it will create a separate webpack chunk, and generate a facade class instantiating which gives us the worker instance. This worker instance happens to be what Comlink.proxy expects, so we can just pass it on.

// expensive-processor.worker.ts
class ExpensiveProcessor {
    /* ... async methods here ... */
}

Comlink.expose(ExpensiveProcessor, self);
// main.ts

import ExpensiveProcessor from "worker-loader!../core/expensive-processor.worker";

// Later inside an async function:
const ProcessorFacade = Comlink.proxy(new ExpensiveProcessor());
const processor = await new ProcessorFacade();

Typescript integration

The last piece here is to make typescript play well. Because as of now, typescript has no way to figure out the type of the imported ExpensiveProcessor because the code generated by webpack doesn't go through the type checker.

The first step is to declare an ambient module having a wildcard declaration for all worker-loader related imports so that worker-loader imports are identified as Worker implementations.

declare module "worker-loader!*" {
    class WebpackWorker extends Worker {
        constructor();
    }
    export default WebpackWorker;
}

The second step is to pass a type parameter to Comlink.proxy:

const ProcessorFacade = Comlink.proxy<{new (): Promise<IProcessorFacade>}>(new ExpensiveProcessor());
const processor = await new ProcessorFacade();

Here IProcessorFacade is an interface implemented by ExpensiveProcessor that defines a subset of the public API that plays well with structured cloning.

So the type {new (): Promise<IProcessorFacade>} denotes a newable (constructor) which gives us a promise that resolves to an object that complies with IProcessorFacade.

// expensive-processor.worker.ts
interface IProcessorFacade {
    /* ... Methods whose arguments are either compatible with structured-cloning or have been wrapped in 
       Comlink.proxyValue ... */
}

class ExpensiveProcessor implements IProcessorFacade {
    /* ... async methods here ... */
}

Comlink.expose(ExpensiveProcessor, self);

Of course, this generics based implementation is not exactly type safe even though it eliminates all type errors.

The reason is that there is nothing stopping us from exposing something that doesn't implement IProcessorFacade but until the typescript's compile time transformation API gets well supported by all ts ecosystem tooling, the only practical solution is to use a lint rule based on file name conventions if this is a concern.