worker-f
v0.1.10
Published
Runs a function in a separate thread in a web browser or Node.js
Maintainers
Readme
worker-f
Runs a function in a separate thread in a web browser or Node.js.
Why
The code is universal and could be run in any environment: in a web browser or Node.js. The easy-to-use API hides the complexity of dealing with each particular environment.
Doesn't require moving code to a separate file for it to be able to run in a worker. This prevents introducing the unnecessary and redundant concept of being able to access a "filesystem" or having to run a "web server". The code must not concern itself with such things that're completely irrelevant to its purpose. It shouldn't even know that a concept of a "file path" exists. Running a function in a separate thread should be as simple as it is in other programming languages.
Why use workers at all? In a web browser, it's about not freezing the app while doing "heavy computation". In Node.js, it's about not freezing the server while doing "heavy computation". And while freezing an app in a web browser is somewhat bearable, the main appeal of Node.js from the point of its creation has been that it doesn't "fork" the process for each incoming HTTP request, outperforming "classic" web servers by using an "event loop" instead, which quickly backstabs if that "event loop" is accidentally blocked by a "heavy computation".
Install
npm install worker-fUse
Basic usage:
const workerFn = workerFunction((a, b) => a + b)
await workerFn.callOnce(1, 2) === 3Advanced usage scenarios like "calling multiple times", "streaming", "caching", "transferring data" are described further in this document.
Import
This package provides a separate import path for each different environment, as described below.
Browser
import workerFunction from 'worker-f/browser'
const workerFn = workerFunction((a, b) => a + b)Node
import workerFunction from 'worker-f/node'
const workerFn = workerFunction((a, b) => a + b)API
Call Once
Use this when the function will only be called once. Attempting to call the function second time will throw an error.
The function could be synchronous or asynchronous.
import workerFunction from 'worker-f/node'
const workerFn = workerFunction((a, b) => a + b)
await workerFn.callOnce(1, 2) === 3Call
When the function will be called multiple times, it should be started, then called as many times as needed, then stopped.
import workerFunction from 'worker-f/node'
const workerFn = workerFunction((a, b) => a + b)
workerFn.start()
await workerFn.call(1, 2) === 3
await workerFn.call(4, 5) === 9
workerFn.stop()If the function rejects or throws an error, it will automatically stop.
If a developer forgets to stop a worker function that is no longer used, it will still stop automatically when the code no longer holds any "reference" to it. It will also stop automatically when the web browser tab is closed, or the Node.js process is killed. But until stopped, it will keep holding its memory.
Stream
If a function is designed to produce multiple outputs over time, it should use "stream" API rather than "call" API.
import workerFunction, { type Send } from 'worker-f/node/stream'
const workerFn = workerFunction(
// Use `send()` to output anything from the function
(send: Send<number>) => {
// The code here will be run on `start()`
// and anything declared here will exist until `stop()`,
// so this is the place to define the function's state
let counter = 0
// Return an "input handler" function
return (input: number) => {
counter++
send(input*input)
}
}
)
// The sum of all responses from the function
let total = 0
// How many responses it expects from the function
let pending = 0
// Listen to the responses from the function
workerFn.onOutput((output) => {
total += output
pending--
// When all responses are received
if (pending === 0) {
// Validate the end result
total === 14
// Stop the worker function
workerFn.stop()
}
})
// Listen to any errors thrown by the function
workerFn.onError((error) => {
// Handle the error here
console.error(error)
// The worker function will automatically stop after this
})
workerFn.start()
pending++
workerFn.send(1) // 1 * 1 === 1
pending++
workerFn.send(2) // 2 * 2 === 4
pending++
workerFn.send(3) // 3 * 3 === 9External Dependencies
Sometimes, a function is not "self-contained", and it references some variables or other functions from outside of the function body. In that case, those variables or functions must be specified as the function's "dependencies", or else it would throw a ReferenceError: <name> is not defined.
// External variable
const c = 3
// External function
const d = () => 4
// This worker function references `c` and `d` which are outside of its body
const workerFn = workerFunction((a, b) => a + b + c + d())
// Without declaring `c` and `d` as "dependencies",
// it throws: "ReferenceError: c is not defined"
await workerFn.callOnce(1, 2)
// How to fix the error:
workerFn.addDependencies(() => [c, d])
// Now it works
await workerFn.callOnce(1, 2) === 10Any external dependencies that're functions must either be "self-contained" functions or specify their own external dependencies in the .addDependencies(...) call. And the loop continues until the very last sub-sub-sub-dependency function is finally "self-contained".
// External function that is "self-contained" because it doesn't reference anything outside of its body
const d = () => 4
// External function that references `d` which is outside of its body
const c = () => 3 + d()
// This worker function references `c` which is outside of its body
const workerFn = workerFunction((a, b) => a + b + c())
// Specifying just `c` is not enough because `d` is also an external sub-dependency
workerFn.addDependencies(() => [c])
// throws: "ReferenceError: d is not defined"
await workerFn.callOnce(1, 2)
// How to fix the error:
workerFn.addDependencies(() => [c, d])
// Now it works
await workerFn.callOnce(1, 2) === 10To reduce the number of external dependencies, one could put everyting into a single large "self-contained" function that doesn't reference anything outside of its body. Or if it does reference anything outside of its body, those references themselves must be "self-contained" functions that don't reference anything outside of their body, etc.
With that in mind, one could see how specifying all the dependencies correctly could become a tedious task in a typical modular application where the code is spread over countless smaller modules, each of them importing other smaller modules, etc. So a better approach would be to just move everything — the function itself and most of its dependencies — into a single big "wrapper" function, as if we're back in 2000s, and then create a worker from it.
// A "wrapper" function that has the same arguments as the original function
export function fn_(a, b) {
// Any dependencies are put right here, inside the wrapper function body
const d = () => 4
const c = () => 3 + d()
// The original function is also put here
const fn = (a, b) => a + b + c()
// Call the original function with the arguments
return fn(a, b)
}// Create a worker from the "wrapper" function.
// No need to specify any external dependencies because there're none. Simple.
const workerFn = workerFunction(fn_)Needless to say that after a worker function has been started, none of its dependencies should change because those changes won't be reflected inside the worker function's thread, i.e. it won't "see" any changes.
Caching
Every time a new worker function is created, it has to stringify the function body in order to generate the worker's code. If the application plans on creating many workers from same function, and that function has no external dependencies or those dependencies are constant, then it would make sense to only generate the function's source code once and then reuse it every time a new worker is created from this function.
To "cache" a function's source code for creating future workers, call .alias() method on the worker function.
const c = () => 1
const sum = (a, b) => a + b + c()
const sumFn = workerFunction(sum)
sumFn.addDependencies(() => [c])
// Calling `.alias()` creates a snapshot of this worker function.
// After assigning an alias, one could instantiate this type of worker function from the snapshot.
// The snapshot will include both the `sum` function body and any of its dependencies.
//
// Creating a new worker function from an alias will be slightly faster than from a function body
// because it won't have to redo the stringification of the function body and any of its dependencies.
// Does it really matter performance-wise? I didn't bother checking.
//
sumFn.alias('sum')
// (optional)
await sumFn.callOnce(2, 3) === 6
// Create a new worker function by the alias.
const sumFn2 = workerFunction<Args, Result>('sum')
await sumFn2.callOnce(4, 5) === 10
// Create a new worker function by the alias.
const sumFn3 = workerFunction<Args, Result>('sum')
await sumFn3.callOnce(6, 7) === 14Performance
By default, any input passed to a worker function is cloned behind the scenes. And same goes for any output.
Because of how seamless the "cloning" is, developers don't even have to bother knowing that it takes place.
Yet, in some situations, the data being passed between the main thread and the worker thread might become large-enough to justify tinkering with potential optimization.
How large is "large-enough"?
For JSON objects, the deeper the object is, the more costly it is to "serialize" and "deserialize" it back. There're some benchmarks from 2019 where it shows how "serializing"/"deserializing" a
10 MBJSON object with6levels of nesting is about50 mson a desktop or100 mson a phone.For
ArrayBuffers, the cloning is said to be "incredibly quick" without any further details.
So the short answer is: "I personally don't really know or care". The rule of thumb is to keep the data being sent between the main thread and the worker thread to a minimum.
Sidenote: The "cloning" is "synchronous" so it blocks the main thread until it finishes cloning.
Transfer List
When passing ArrayBuffers, there's an optional feature called "transfer". When it "transfers" a buffer, it doesn't clone it, but instead it simply "transfers" the ownership of the buffer from the "main thread" to the "worker thread", and vice versa, which is a "free" operation. Although note that after a buffer has been "transferred", it's no longer usable in the code that "transferred" it.
To enable "transfer" for certain input/output buffers, call inputTransferList() / outputTransferList() methods on a worker function.
// A worker function with some input and output.
const parser = workerFunction((arrayBuffer, dataType) => {
// ... Parse the data from the buffer according to the data type ...
return data
})
// Pass a function that returns a `transferList` for the input of the worker function.
// By default, an empty `transferList` is used for the input of the worker function.
parser.inputTransferList((arrayBuffer, dataType) => [arrayBuffer])
// Pass a function that returns a `transferList` for the output of the worker function.
// By default, an empty `transferList` is used for the output of the worker function.
parser.outputTransferList((data) => [])
// Now, when passing an `arrayBuffer` to the worker function,
// it will be "transferred" from the main thread to the worker thread.
const data = await parser.callOnce(arrayBuffer, 'document')Errors
When using "call" API, if the function rejects or throws an error, the returned Promise will be rejected and the worker function will automatically stop.
When using "stream" API, the optional .onError() listener will be called and the worker function will automatically stop.
Development
npm install
npm testIt uses vitest to run unit tests, which also comes with a bug on Windows when it doesn't know how to properly handle lowercase drive letter. The error message is Error: Vitest failed to find the current suite. One of the following is possible. The workaround is to cd into same directory but with an uppercase drive letter.
