Skip to content

Instantly share code, notes, and snippets.

@RedstoneWizard22
Last active November 1, 2023 03:05
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save RedstoneWizard22/d07b326a438dd0449758c263cebd0e82 to your computer and use it in GitHub Desktop.
Save RedstoneWizard22/d07b326a438dd0449758c263cebd0e82 to your computer and use it in GitHub Desktop.
Make web workers fun! A tiny library allowing you to use web workers via asynchronous proxy functions!

promise-worker.ts

Make web workers fun!

This tiny library allows you use web workers via asynchronous proxy functions! No more struggling with mesages and event handlers - all this is done automatically. Exposed worker functions return promises resolving with their results.

  • Full typescript support
  • Bundler independent
  • Perfect with async/await
  • Tiny size

This was written as an alternative to workerize-loader which currently lacks support for webpack 5. The code is tiny and contained in a single file, I encourage you to check it out!

Usage

Using this library is simple, all you need is two functions!

  1. Use expose() at the end of your worker to make functions callable from the master thread. This takes in an object whose entries are functions, these can be async.

  2. Load the worker in your script however you like, e.g. worker-loader

  3. Create a new instance of the worker and pass it into wrap() to create the wrapper object

  4. You can now call worker functions "directly" using the wrapper! E.g. const result = await wrapper.someFunction(). They will all return promises which resolve with the result or reject with an error if one occurs. You can also use wrapper.terminate() to terminate the worker

See the example below:

worker.ts

import { expose } from './promise-worker';

function add(a: number, b: number) {
  return a + b;
}

function multiply(a: number, b: number) {
  return a * b;
}

expose({ add, multiply });

// Export the type for type checking
const workerFunctions = { add, multiply };
type WorkerFunctions = typeof workerFunctions;
export type { WorkerFunctions };

index.ts

import { wrap } from './promise-worker';
// You may load the worker as you like, I use worker-loader as it requires no configuration
import DemoWorker from 'worker-loader!./worker';

import type { WorkerFunctions } from './worker';

async function run() {
  // Create the worker & wrapper
  const wrapper = await wrap<WorkerFunctions>(new DemoWorker());

  // Use the worker
  const a = 2,
    b = 5;

  const added = await wrapper.add(2, 5);
  const multiplied = await wrapper.multiply(2, 5);

  console.log(`${a} + ${b} = ${added}`);
  console.log(`${a} * ${b} = ${multiplied}`);

  // Terminate the worker
  wrapper.terminate();
}

void run();

Explanation

The code is small so understanding it with a look through should be enough. I'm also not great at explaining :/ But here's an explanation none the less:

The basic idea is: You can access properties of an object by name house["price"] so you can call functions within an object by name. Our requests to the worker can contain a function name action to call and array of arguments to supply payload. The worker can then call the requested function with the the given arguments, and post the result back. We can add a try catch statement (in the worker) to detect errors, so our response has a type type = "success" | "error" and value payload. Finally we add a unique id property to the requests and responses to be able to match the two.

expose() adds an onmessage event listener to the worker which does just this. But how do we create the proxy functions for the wrapper? Firstly using the id feature, the wrapper can keep track of the requests as promises - create Promise and post request with id, when response with matching id is found resolve (or reject) the corresponding promise with the result. createJob() (line 130) creates and returns these promises. The wrapper must know what functions are available in the worker, so expose() also adds a getFunctionality function which returns a list of all exposed function names. The wrap() factory function requests this, then for each entry creates a corresponding proxy function in the wrapper which simply calls createJob() (line 179). Finally we add a terminate function to the wrapper and return it.

Now we can just call the proxy functions and get a promise back, leaving the wrapper to handle the actual interaction with the worker.

/**
* promise-worker.ts v1.0
*
* Make using web workers as easy as calling functions!
*
* For usage & details see README:
* https://gist.github.com/RedstoneWizard22/d07b326a438dd0449758c263cebd0e82
*
*/
//////// Types ////////
/** An object containing the exposed functions (see `expose`) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ExposedFunctions = Record<string, (...args: any[]) => any>;
/** Message sent to the worker */
type request = {
/** job id */
id: number;
/** Function to execute */
action: string;
/** Arguments to pass to function */
payload: unknown[];
};
/** Message received from the worker */
type response = {
/** job id */
id: number;
/** :] */
type: 'success' | 'error';
/** e.g. the execution result / caught error */
payload: unknown;
};
/** The wrapper used to interface with the worker. It has an async wrapper function for
* every exposed function, and a `terminate` function to kill the worker
*/
type PromiseWorker<T extends ExposedFunctions> = {
// Make all functions in T return promises
[K in keyof T]: T[K] extends (...args: infer Args) => infer Result
? (
...args: Args
) => Result extends Promise<unknown> ? Result : Promise<Result>
: never;
} & { terminate: () => void };
//////// Functions ////////
/**
* Expose a set of functions to be callable from the master thread via a PromiseWorker wrapper
* (see `wrap`)
* @param [functions] An object whose entries are the functions to be exposed
*/
function expose(functions: ExposedFunctions): void {
// Ensure we are running in a worker
if (typeof WorkerGlobalScope === 'undefined') {
console.error('Expose not called in worker thread');
return;
}
const onSuccess = function (request: request, result: unknown) {
postMessage({
id: request.id,
type: 'success',
payload: result,
} as response);
};
const onError = function (request: request, error: unknown) {
postMessage({ id: request.id, type: 'error', payload: error } as response);
};
/** Returns a list of names of all exposed functions */
const getFunctionality = function () {
const functionality = Object.keys(functions).filter(
(key) => typeof functions[key] === 'function'
);
return functionality;
};
/** Executes the function corresponding to a request and returns a promise of the result */
const exec = function (request: request): Promise<unknown> {
const func = functions[request.action];
const args = request.payload;
const result = func(...args) as unknown;
if (result instanceof Promise) {
return result;
}
return Promise.resolve(result);
};
onmessage = async function (message: MessageEvent<request>) {
const request = message.data;
// We must catch any errors so we can match them to a request
try {
let result: unknown;
if (request.action === 'getFunctionality') {
result = getFunctionality();
} else {
result = await exec(request);
}
onSuccess(request, result);
} catch (e) {
onError(request, e);
}
};
}
/** Takes in an exposed worker, returns the PromiseWorker wrapper used to interact with it
* (see README for details)
*/
async function wrap<T extends ExposedFunctions>(
worker: Worker
): Promise<PromiseWorker<T>> {
type job = {
request: request;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
};
let jobId = 0;
const activeJobs: job[] = [];
/** Creates and runs a new job, returns a promise for it's result */
const createJob = function (temp: Pick<request, 'action' | 'payload'>) {
const request = { ...temp, id: jobId++ };
const jobResult = new Promise((resolve, reject) => {
activeJobs.push({ request, resolve, reject });
});
worker.postMessage(request);
return jobResult;
};
worker.onmessage = function (message: MessageEvent<response>) {
const response = message.data;
const jobIndex = activeJobs.findIndex(
(job) => job.request.id == response.id
);
if (jobIndex < 0) {
console.error('Worker responded to nonexistent job');
console.warn("Worker's response:", response);
return;
} else {
const job = activeJobs.splice(jobIndex, 1)[0];
response.type == 'success'
? job.resolve(response.payload)
: job.reject(response.payload);
}
};
worker.onerror = function (error) {
// We don't actually know what job the error occured in, so reject them all just to be safe.
// This event should never fire since we have a try catch within the worker's onmessage
console.error('Uncaught error in worker:', error);
const jobs = activeJobs.splice(0, activeJobs.length);
jobs.forEach((job) => job.reject(error));
};
/// Create the wrapper
// Obtain a list of functions available in the worker
const functionality = (await createJob({
action: 'getFunctionality',
payload: [],
})) as string[];
// Create proxy functions for these
const wrapper = {} as ExposedFunctions;
functionality.forEach(
(item) =>
(wrapper[item] = (...args: unknown[]) =>
createJob({ action: item, payload: args }))
);
// Add the termination function
wrapper.terminate = () => worker.terminate();
return wrapper as PromiseWorker<T>;
}
//////// Exports ////////
export { expose, wrap };
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>
@TobbeLundberg
Copy link

@RedstoneWizard22 What's the license of this?

@gone-skiing
Copy link

@RedstoneWizard22 same question on the license? We are hitting this issue with react app after webpack upgrade.

@RedstoneWizard22
Copy link
Author

@gone-skiing @TobbeLundberg

Shoot, sorry I missed that first comment. The licence for this is the unlicense license. I've added UNLICENSE.txt to the gist. Hope that helps!

@TobbeLundberg
Copy link

Thanks @RedstoneWizard22 that's perfect 🙂

@gone-skiing
Copy link

+1 Thanks so much!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment