Type-safe Workers using WorkerProxy

By default, all of your JavaScript code executes on a single thread - the same thread that is also responsible for handling user interaction and rendering the document. If your code takes too long to execute, it can introduce a degraded or even unusable user experience by blocking the main thread. But some tasks are intrinsically more complicated, requiring a long or simply indeterminate amount of time to complete.

JavaScript provides Workers as a solution to this problem, enabling chunks of code to execute on a background thread, often receiving input from and returning output to the main thread. The postMessage and onmessage methods of Window and Worker orchestrate this communication. However, these are low-level APIs that require you to manage the following concerns:

  • Defining the set of operations that the Worker provides.
  • Ensuring that the two threads agree on the types of the inputs and outputs of each operation.
  • Propagating errors between threads.
  • Associating each outgoing message with the corresponding incoming response.
  • Leveraging transferable objects to avoid unnecessary, expensive copying of data between threads.

WorkerProxy provides a type-safe wrapper around a Worker that permits you to define an interface describing your Worker's operations and create a proxy object to invoke those operations on your Worker and receive the results.

Example: Calculator

To illustrate the basics of WorkerProxy, let's use a contrived example of a "calculator". Here's the interface describing the operations the calculator provides:

interface Calculator {
  /** Returns the constant PI. */
  pi(): number;
  /** Returns the square root of `num`. Throws an error if `num` is less than zero. */
  squareRoot(num: number): number;
  /** Returns the sum of `a` and `b`. */
  add(a: number, b: number): number;
  /** Divides each of the `numbers` by the specified `divisor`. */
  divideAll(numbers: Float64Array, divisor: number): Float64Array;
}

Note that it defines synchronous functions that accept 0, 1, or 2 arguments and returning a single value.

Next, let's write the script that will implement the Calculator interface, to be run on a Worker:


registerWorker<Calculator>({
  pi: () => Math.PI,
  squareRoot: (num) => {
    if (num < 0) {
      throw new Error("squareRoot requires a non-negative input");
    }

    return Math.sqrt(num);
  },
  add: (args: [a: number, b: number]) => {
    return args[0] + args[1];
  },
  divideAll: (args: [numbers: Float64Array, divisor: number]) => {
    const result = args[0];
    const divisor = args[1];
    for (let i = 0; i < result.length; i++) {
      result[i] = result[i] / divisor;
    }

    const transfer = [result.buffer];
    return { result, transfer };
  },
});

Note that the function signatures of pi, which takes zero arguments, and squareRoot, which takes one argument, are identical to those in the Calculator interface. But the signatures for add and divideAll, which each take two arguments, have changed to accept the arguments as a tuple containing two values. The type of each element of the tuple matches the type of the corresponding argument in the Calculator method. You'll see why momentarily, but the rule is that for any function in the interface accepting two or more parameters, the parameters are converted to a single tuple containing the same number and types of elements as defined by the parameter list.

Further, note that while Calculator.divideAll is defined to return a number, our implementation returns { result: number, transfer: Transferable[] }. This permits the implementation to specify that the ArrayBuffer backing the Float64Array should be cheaply transferred back to the main thread, instead of making a potentially expensive copy. Any function declared by the worker's interface as returning a type T may opt to instead return { result: T, transfer: Transferable[] } in the same manner.

From the main thread, we can instantiate a WorkerProxy based on our Calculator interface by invoking createWorkerProxy, supplying the absolute or relative URL of the script containing our call to registerWorker:

const calculator = createWorkerProxy<Calculator>("./calculator.js");

We can then invoke the operations of the calculator:

const pi = await calculator.pi();
assert(pi === Math.PI);

const three = await calculator.squareRoot(9);
assert(three === 3);

const five = await calculator.add([2, 3]);
assert(five === 5);

Note that we must await the result of each operation, because we are communicating asynchronously with a Worker running on a different thread. Also note that when we invoke add, which takes two arguments, we must provide the arguments as a tuple: [2, 3]. This is because the WorkerProxy alters the Calculator interface to look like this:

interface CalculatorProxy {
  pi(transfer?: Transferable[]): Promise<number>;
  squareRoot(num: number, transfer?: Transferable[]): Promise<number>;
  add(args: [a: number, b: number], transfer?: Transferable[]): Promise<number>;
  divideAll(args: [numbers: Float64Array, divisor: number], transfer?: Transferable[]): Promise<Float64Array>;
  /** From WorkerProxy, terminates the Worker. */
  terminate(): void;
  /** From WorkerProxy, true if `terminate` has been called. */
  readonly isTerminated: boolean;
}

Again, each method of Calculator taking more than one argument is transformed into a method taking those same arguments as a single tuple. Each method also becomes async, returning a Promise. And each method gains an additional, optional argument - an array of objects to be transferred from the main thread to the Worker.

In the case of divideAll, we want to pass a Float64Array to the Worker, which will modify it and return it back to us. By default, that would involve copying the entire array twice, which could be expensive if the array is relatively large. Instead, we can make use of the transfer argument to avoid making any copies:

const numbers = new Float64Array([1, 2, 3, 4, 5, 6, 7]);
const result = await calculator.divideAll([numbers, 2], [numbers.buffer]);
assert(result.length === 7);
assert(result[0] === 0.5);

We can continue invoking operations on the Worker for as long as we need it. When we're finished with it, we can terminate it:

calculator.terminate();
assert(calculator.isTerminated);

Example: Creating graphics

Graphics creation represents a more realistic use case for Workers than the contrived calculated example above. GraphicBuilder is fine for creating simple RenderGraphics like decorations on the main thread. But imagine you are streaming large data sets like point clouds, GeoJSON, or Shapefiles which you must process into complex graphics - attempting to do so on the main thread would utterly degrade the responsiveness of the application.

GraphicDescriptionBuilder provides almost exactly the same API as GraphicBuilder, except that it can be used on a Worker. Instead of a RenderGraphic, it produces a GraphicDescription that can be efficiently converted into a RenderGraphic on the main thread.

The GraphicCreator interface and related types below illustrate the concept using a simple method that creates a GraphicDescription from a description of any number of circles with location, radius, and color attributes:

/** Arguments supplied to GraphicCreator.createCircles. */
interface CreateCirclesArgs {
  /** The center coordinates and radius of each circle, arranged as quadruplets of 64-bit floats. */
  xyzRadius: Float64Array;
  /** The color of each circle described by `xyzRadius`, as a 32-bit integer a la `ColorDefProps`. */
  color: Uint32Array;
  /** The level of detail in meters at which to tesselate the circles. */
  chordTolerance: number;
  /** Context obtained from the main thread. */
  context: WorkerGraphicDescriptionContextProps;
}

/** The return type of a GraphicCreator method, returning a description of the graphic and the context for its creation. */
interface GraphicCreatorResult {
  context: GraphicDescriptionContextProps;
  description: GraphicDescription;
}

/** Defines the operations of a Worker that creates GraphicDescriptions. */
interface GraphicCreator {
  createCircles(args: CreateCirclesArgs): GraphicCreatorResult;
}

We can implement the createCircles method in a worker script as follows:

registerWorker<GraphicCreator>({
  // Validate the inputs.
  createCircles: (args: CreateCirclesArgs) => {
    const circles = args.xyzRadius;
    const numCircles = circles.length / 4;
    if (numCircles !== Math.round(numCircles)) {
      throw new Error("Four floats per circle are required");
    } else if (numCircles !== args.color.length) {
      throw new Error("The same number of colors and circles are required");
    }

    // Instantiate the context.
    const context = WorkerGraphicDescriptionContext.fromProps(args.context);

    // Allocate a transient Id to serve as the Id of the model (container) for our circles.
    const modelId = context.transientIds.getNext();

    // Create a builder.
    const builder = GraphicDescriptionBuilder.create({
      type: GraphicType.Scene,
      computeChordTolerance: () => args.chordTolerance,
      context,
      pickable: {
        id: modelId,
        modelId,
      },
    });

    // Add each circle to the builder.
    for (let i = 0; i < numCircles; i++) {
      // Set the next circle's color.
      const color = ColorDef.fromJSON(args.color[i]);
      builder.setSymbology(color, color, 1);

      // Assign a unique Id to the circle so it can be interacted with by the user.
      builder.activatePickableId(context.transientIds.getNext());

      // Add the circle to the builder.
      const offset = i * 4;
      const center = new Point3d(circles[offset], circles[offset + 1], circles[offset + 2]);
      const radius = circles[offset + 3];
      const circle = Arc3d.createXY(center, radius);

      builder.addArc(circle, true, true);
    }

    // Extract the finished GraphicDescription.
    const description = builder.finish();

    // Collect any transferable objects - primarily, ArrayBuffers - from the GraphicDescription.
    const transferables = new Set<Transferable>();
    GraphicDescription.collectTransferables(transferables, description);

    // Package up the context to send back to the main thread, including any transferable objects it contains.
    const contextProps = context.toProps(transferables);

    const result: GraphicCreatorResult = {
      description,
      context: contextProps,
    };

    // Return the graphic description and context, transferring any transferable objects to the main thread.
    return {
      result,
      transfer: Array.from(transferables),
    };
  },
});

Then we can define a createCircleGraphic function that can be called from the main thread to create a RenderGraphic, leveraging the Worker defined above to move most of the processing to a background thread:

// Instantiate a reusable WorkerProxy for use by the createCircleGraphic function.
const worker = createWorkerProxy<GraphicCreator>("./graphic-creator.js");

// Create a render graphic from a description of a large number of circles, using a WorkerProxy.
async function createCircleGraphic(xyzRadius: Float64Array, color: Uint32Array, chordTolerance: number, iModel: IModelConnection): Promise<RenderGraphic | undefined> {
  // Package up the RenderSystem's context to be sent to the Worker.
  const workerContext = IModelApp.renderSystem.createWorkerGraphicDescriptionContextProps(iModel);

  // Transfer the ArrayBuffers to the Worker, instead of making copies.
  const transfer: Transferable[] = [xyzRadius.buffer, color.buffer];

  // Obtain a GraphicDescription from the Worker.
  const args: CreateCirclesArgs = {
    xyzRadius,
    color,
    chordTolerance,
    context: workerContext,
  };
  const result = await worker.createCircles(args, transfer);

  // Unpackage the context from the Worker.
  const context = await IModelApp.renderSystem.resolveGraphicDescriptionContext(result.context, iModel);

  // Convert the GraphicDescription into a RenderGraphic.
  return IModelApp.renderSystem.createGraphicFromDescription({
    description: result.description,
    context,
  });
}

Last Updated: 08 August, 2024