npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

kiss-worker

v3.0.0

Published

Provides one of the easiest ways to run a function on a worker thread in the browser.

Downloads

703

Readme

Provides one of the easiest ways to use a worker thread in the browser, in ~2kB additional chunk size!

  1. Features
  2. Prerequisites
  3. Getting Started
  4. Advanced Topics
  5. Limitations
  6. Motivation

Features

  • Full TypeScript support with the best achievable type safety for all client code
  • Fully transparent marshalling of arguments, return values and Error objects
  • Sequentialization of simultaneous calls with a FIFO queue
  • Support for synchronous and asynchronous functions and methods
  • Automated tests for 99% of the code
  • Reporting of incorrectly implemented functions and methods
  • Tree shaking friendly (only pay for what you use)

Prerequisites

This is an ESM-only package. If you're still targeting browsers without ESM support, this package is not for you.

Getting Started

Installation

npm install kiss-worker

Example 1: Single Function

The full code of this example can be found on GitHub and StackBlitz.

// ./src/createFibonacciWorker.ts
import { implementFunctionWorker } from "kiss-worker";

// The function we want to execute on a worker thread
const fibonacci = (n: number): number =>
    ((n < 2) ? Math.floor(n) : fibonacci(n - 1) + fibonacci(n - 2));

export const createFibonacciWorker = implementFunctionWorker(
    // A function that creates a web worker running this script
    () => new Worker(
        new URL("createFibonacciWorker.js", import.meta.url),
        { type: "module" },
    ),
    fibonacci,
);

That's it, we've defined our worker with a single statement! Let's see how we can use this from main.ts:

// ./src/main.ts
import { createFibonacciWorker } from "./createFibonacciWorker.js";

// Start a new worker thread waiting for work.
const worker = createFibonacciWorker();

// Send the argument (40) to the worker thread, where it will be passed
// to our function. In the mean time we're awaiting the returned promise,
// which will eventually fulfill with the result calculated on the worker
// thread.
const result = await worker.execute(40);

const element = document.querySelector("h1");

if (element) {
    element.textContent = `${result}`;
}

Here are a few facts that might not be immediately obvious:

  • Each call to the createFibonacciWorker() factory function starts a new and independent worker thread. If necessary, a thread could be terminated by calling worker.terminate().
  • The signature of worker.execute() is equivalent to the one of fibonacci(). Of course, Errors thrown by fibonacci() would also be rethrown by worker.execute(). The only difference is that worker.execute() is asynchronous, while fibonacci() is synchronous.
  • All involved code is based on ECMAScript modules (ESM), which is why we must pass { type: "module" } to the Worker constructor. This allows us to use normal import statements in ./src/createFibonacciWorker.ts (as opposed to importScripts() required inside classic workers).
  • ./src/createFibonacciWorker.ts is imported by code running on the main thread and is also the entry point for the worker thread. This is possible because implementFunctionWorker() detects on which thread it is run. However, this detection would not work correctly, if code in a worker thread attempted to start another worker thread. This can easily be fixed, see Worker Code Isolation.
  • In order for build tools to be able to put worker code into a separate chunk, it is vital that the expression () => new Worker(new URL("createFibonacciWorker.js", import.meta.url), { type: "module" }) is kept as is. Please see associated instructions for vite and webpack. Other build tools will likely have similar constraints.

Example 2: Object

The full code of this example can be found on GitHub and StackBlitz.

Sometimes it's not enough to serve just a single function on a worker thread, which is why this library also supports serving objects:

// ./src/createCalculatorWorker.ts
import { implementObjectWorker } from "kiss-worker";

// We want to serve an object of this class on a worker thread
class Calculator {
    public multiply(left: bigint, right: bigint) {
        return left * right;
    }

    public divide(left: bigint, right: bigint) {
        return left / right;
    }
}

export const createCalculatorWorker = implementObjectWorker(
    // A function that creates a web worker running this script
    () => new Worker(
        new URL("createCalculatorWorker.js", import.meta.url),
        { type: "module" },
    ),
    Calculator,
);
// ./src/main.ts
import { createCalculatorWorker } from "./createCalculatorWorker.js";

// Start a new worker thread waiting for work.
const worker = await createCalculatorWorker();

const element = document.querySelector("p");
let current = 2n;

for (let round = 0; element && round < 20; ++round) {
    // worker.obj is a proxy for the Calculator object on the worker
    // thread
    current = await worker.obj.multiply(current, current);
    element.textContent = `${current}`;
}

More facts that might not be immediately obvious:

  • Contrary to implementFunctionWorker(), the factory function created by implementObjectWorker() returns a Promise. This is owed to the fact that the passed constructor is executed on the worker thread. So, if the Calculator constructor threw an error, it would be rethrown by createCalculatorWorker().
  • worker.obj acts as a proxy for the Calculator object served on the worker thread. worker.obj thus offers the same methods as a Calculator object, again with equivalent signatures.

Advanced Topics

Asynchronous Functions and Methods

These are fully supported out of the box, no special API needed.

Simultaneous Calls

If client code does not await each call to execute or methods offered by the obj property of a given worker, it can happen that a call is made even though a previously returned promise is still unsettled. In such a scenario the later call is automatically queued and only executed after all previously returned promises have settled.

Worker Code Isolation

As hinted at above, the implementation of a worker in a single file has its downsides, which is why it's sometimes necessary to fully isolate worker code from the rest of the application. For this purpose implementFunctionWorkerExternal() and implementObjectWorkerExternal() are provided. Using these instead of their counterparts has the following advantages:

  • A factory function returned by implementFunctionWorkerExternal() or implementObjectWorkerExternal() can be executed on any thread (not just the main thread).
  • The code of the served function or object is only ever loaded on the worker thread. This can become important when the amount of code running on the worker thread is significant, such that you'd rather not load it anywhere else.

Lets see how Example 1 can be implemented such that worker code is fully isolated.

The full code of this example can be found on GitHub and StackBlitz.

// ./src/fibonacci.ts
import { serveFunction } from "kiss-worker";

// The function we want to execute on a worker thread
const fibonacci = (n: number): number =>
    ((n < 2) ? Math.floor(n) : fibonacci(n - 1) + fibonacci(n - 2));

// Serve the function so that it can be called from the thread executing
// implementFunctionWorkerExternal
serveFunction(fibonacci);

// Export the type only
export type { fibonacci };
// ./src/createFibonacciWorker.ts
import { FunctionInfo, implementFunctionWorkerExternal } from
    "kiss-worker";

// Import the type only
import type { fibonacci } from "./fibonacci.js";

export const createFibonacciWorker = implementFunctionWorkerExternal(
    // A function that creates a web worker running the script serving
    // the function
    () => new Worker(
        new URL("fibonacci.js", import.meta.url),
        { type: "module" },
    ),
    new FunctionInfo<typeof fibonacci>(),
);

The usage from ./src/main.ts is the same as in Example 1. What was done in a single file before is now split into two. Note that ./src/fibonacci.ts only exports a type, so we can no longer pass the function itself. Instead, we pass a FunctionInfo instance to convey the required information. Type-only exports and imports are removed during compilation to ECMAScript.

Finally, let's see how Example 2 can be implemented such that worker code is fully isolated.

The full code of this example can be found on GitHub and StackBlitz.

// ./src/Calculator.ts
import { serveObject } from "kiss-worker";

// We want to serve an object of this class on a worker thread
class Calculator {
    public multiply(left: bigint, right: bigint) {
        return left * right;
    }

    public divide(left: bigint, right: bigint) {
        return left / right;
    }
}

// Pass the constructor function of the class so that the worker thread
// can create a new object and its methods can be called from the thread
// executing implementObjectWorkerExternal
serveObject(Calculator);

// Export the type only
export type { Calculator };
// ./src/createCalculatorWorker.ts
import { ObjectInfo, implementObjectWorkerExternal } from "kiss-worker";

// Import the type only
import type { Calculator } from "./Calculator.js";

export const createCalculatorWorker = implementObjectWorkerExternal(
    // A function that creates a web worker running the script serving
    // the object
    () => new Worker(
        new URL("Calculator.js", import.meta.url),
        { type: "module" },
    ),
    // Provide required information about the served object
    new ObjectInfo<typeof Calculator>(),
);

The usage from ./src/main.ts is the same as in Example 2. Again, note that ./src/Calculator.ts only exports a type, so we can no longer pass the constructor function itself. Instead, we pass an ObjectInfo instance to convey the required information.

Limitations

  • Transferable objects are not currently passed as transferable, they are thus always copied. Support would be easy to add if it was acceptable for a given worker that all transferable objects are either always or never transferred.
  • At compile time, the interface of a served object is assumed to consist of all properties with a string key. At runtime, the object and its prototype chain is examined with Object.getOwnPropertyNames(). The former will only return properties declared public in the TypeScript code while the latter will return all properties except those with a name staring with #. To avoid surprises, it is best to ensure that both sets of properties are identical, which can easily be achieved by not declaring anything protected or private.
  • The public interface of an object served on a worker thread cannot currently consist of anything else than methods, which is enforced at compile time. The rationale is documented on MethodsOnlyObject.

Motivation

You probably know that blocking the main thread of a browser for more than 50ms will lower the Lighthouse score of a site. That can happen very quickly, e.g simply by using a crypto currency library.

Web Workers are Surprisingly Hard to Use

While Web Workers seem to offer a relatively straight-forward way to offload such operations onto a separate thread, it's surprisingly hard to get them right. Here are just the most common pitfalls (you can find more in the tests):

  • A given web worker is often used from more than one place in the code, which introduces the danger of overlapping requests with several handlers simultaneously being subscribed to the "message" event. Doing so almost certainly introduces subtle bugs.
  • Code executing on the worker might throw to signal error conditions. Such an unhandled exception in the worker thread will trigger the "error" event, but the calling thread will only get a generic Error. The original Error object is lost.

Requirements for a Better Interface

The Web Workers interface was designed that way because it has to cover even the most exotic use cases. I would claim you usually just need a transparent way to execute a single function or methods of an object on a different thread. Since Web Workers aren't exactly new, on npm there are hundreds of packages that attempt to do just that. The ones I've seen all fail to satisfy at least one of the following requirements:

  1. Provide TypeScript types and offer fully transparent marshalling of arguments, return values and Error objects. In other words, calling a function on a worker thread must feel much the same as calling the function on the current thread. To that end, it is imperative that the interface is Promise-based so that the caller can use await.
  2. Follow the KISS principe (Keep It Simple, Stupid). In other words, the interface must be as simple as possible but no simpler. Many libraries disappoint in this department, because they've either failed to keep up with recent language improvements (e.g. async & await) or resort to simplistic solutions that will not work in the general case (e.g. sending a string representation of a function to the worker thread).
  3. Cover the most common use cases well and leave the more exotic ones to other libraries. This approach minimizes the cost in the form of additional chunk size and thus helps to keep your site fast and snappy. For example, many of the features offered by the popular workerpool will go unused in the vast majority of the cases. Unsurprisingly, workerpool is >3 times larger than this library (minified and gzipped). To be clear: I'm sure there is a use case for all the features offered by workerpool, just not a very common one.
  4. Automatically test all code of every release and provide code coverage metrics.
  5. Last but not least: Provide comprehensive tutorial and reference documentation.