Provides one of the easiest ways to use a worker thread in the browser, in ~2kB additional chunk size!
- 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)
This is an ESM-only package. If you're still targeting browsers without ESM support, this package is not for you.
npm install kiss-worker
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 callingworker.terminate()
. - The signature of
worker.execute()
is equivalent to the one offibonacci()
. Of course,Error
s thrown byfibonacci()
would also be rethrown byworker.execute()
. The only difference is thatworker.execute()
is asynchronous, whilefibonacci()
is synchronous. - All involved code is based on ECMAScript modules (ESM), which is why we must pass
{ type: "module" }
to theWorker
constructor. This allows us to use normalimport
statements in ./src/createFibonacciWorker.ts (as opposed toimportScripts()
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.
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 byimplementObjectWorker()
returns aPromise
. This is owed to the fact that the passed constructor function is executed on the worker thread. So, if theCalculator
constructor threw an error, it would be rethrown bycreateCalculatorWorker()
. - If the
Calculator
constructor required parameters, we'd need to pass the associated arguments tocreateCalculatorWorker()
. worker.obj
acts as a proxy for theCalculator
object served on the worker thread.worker.obj
thus offers the same methods as aCalculator
object, again with equivalent signatures.
These are fully supported out of the box, no special API needed.
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.
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()
orimplementObjectWorkerExternal()
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.
- 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 withObject.getOwnPropertyNames()
. The former will only return properties declaredpublic
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 anythingprotected
orprivate
. - 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
.
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.
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 genericError
. The originalError
object is lost.
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:
- 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 isPromise
-based so that the caller can useawait
. - 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). - 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 byworkerpool
, just not a very common one. - Automatically test all code of every release and provide code coverage metrics.
- Last but not least: Provide comprehensive tutorial and reference documentation.