Setup

The library works as follows:

  • You define the messaging protocol as TypeScript interfaces
  • Run the CLI tool to generate code in your project
  • Use the code!

To get started, install the CLI tool with

cargo install --git https://github.com/Pistonite/workex

All runtime dependencies are generated directly in your project when you run workex. This ensures the generated code is always compatible with the workex library, and has the correct import paths. The library should be processed along with your source code using a bundler like Vite for effective tree-shaking.

After installing, you can run workex --help to see the available options.

The basic usage is

workex INPUT [...INPUTS] --protocol PROTOCOL

PROTOCOL is an identifier for all the interfaces in the input files, for example, my-awesome-app. The protocol identifies which set of interfaces to use when a Worker is bound to multiple protocols.

The next section explains the input format.

TS Config

The generated code is in TypeScript, so your project need to use TypeScript. Additionally, make sure you have the following compiler options set in your tsconfig.json

{
    "compilerOptions": {
        /* ... your other options ... */

        /* 
         * The generated code uses .ts in the imports,
         * This helps modern build tools like Vite to resolve
         * the import faster
         */
        "allowImportingTsExtensions": true,
    },
}

TS Interface Input

The CLI program takes in a number of TypeScript files that contain export interface declarations.

For example:

// the workex library will be generated after you run the tool once
import type { WorkexPromise as Promise } from "./workex";

/** Comments here are kept */
export interface Foo {
  /**
   * Comments here are also kept
   */
  doStuff1(): Promise<void>;
  /// Rust styles will also be kept, if you like them
  doStuff2(arg1: string, arg2: string): Promise<string>;
}

Note that all other exports are ignored, including:

  • export type
  • declare

Tip

If you need to exclude some interfaces from export, put them in another file that's not part of the INPUTS.

All import statements will also be included in the output, no unused import analysis is done. Note that since output is one interface per file, it might contain TypeScript unused import errors. If that happens, you can:

  • Separate the interfaces into different files
  • Add // @ts-ignore to the input file (not recommended)

Some syntaxes are not supported, which will result in an error:

  • namespaces
  • imports in the middle of exports

Danger

Relative imports are also currently not supported with the exception of importing from workex (the generated library). You can use baseUrl in tsconfig.json to map the paths to the correct location.

Additionally, all input files also must be in the same directory, which will also be the output directory

Interface Requirements

The input interfaces need to satisfy the following requirement:

  • All members need to be regular functions (not get or set)
  • Return type needs to be WorkexPromise, which is a Promise<WorkexResult<T>>
    • Typically, you can import { WorkexPromise as Promise } and use it as if it's a regular Promise
    • This type means you need to handle potential errors during the message exchange, before accessing T (which itself can be a Result)

Annotations

The tool also looks for annotations in the comments for the interfaces. It supports the following annotations:

  • @workex:send SIDE - Marks SIDE as the send side of the interface
  • @workex:recv SIDE - Marks SIDE as the receive side of the interface

SIDE can be any string and is used to group outputs into re-exports. For example:

import type { WorkexPromise as Promise } from "./workex";

/**
 * @workex:send client
 * @workex:recv worker
 */
export interface A {
    ...
}

/**
 * @workex:send worker
 * @workex:recv client
 */
export interface B {
    ...
}

With this setup, you can import everything the client side needs from one file client.ts, which re-exports the send implementation for A and the receive implementation for B.

Output

The outputs are:

  • workex directory containing the library used by the generated code
  • interfaces directory containing:
    • One Foo.send.ts and Foo.recv.ts for each export interface Foo
      • send is consumed by the side that calls the Foo interface, by using the FooClient class
      • recv is consumed by the side that implements the Foo interface, by calling bindFooHost function
  • sides directory containing one .ts file per side declared with @workex:send or @workex:recv annotations in the input. See TS Interface Input for more information.

An example directory tree might look like this after running workex:

input.ts (this is the input file)

workex/
  index.ts
  ... other files

interfaces/
  Foo.send.ts
  Foo.recv.ts
  Bar.send.ts
  Bar.recv.ts

sides/
  client.ts
  worker.ts

By default, a .gitignore will also be generated in the output to ignore the output directories

Usage: Send-side

On the send (i.e. calling) side, use the FooClient class generated per Foo interface.

import { FooClient } from "my/out/dir/sides/client.ts";

// Anything that looks like `WorkerLike` is accepted
// for example, new Worker(url)
const worker = getMyWorker();
const foo: Foo = new FooClient({
  worker,
  // if true, onmessage will be assigned instead of using addEventListener
  // false is the default
  assign: false,
});

// result will either be the return value, or a WorkexError,
// which could be an exception thrown on the other side, or an internal error
const result = await foo.doStuff1();

// When calling terminate, it will stop handling any return result and newer
// requests will return an error "Terminated"
// If `terminate` is a function on the worker (for Worker objects), it will also
// call that
foo.terminate();

See types.ts for more options available

Usage: Recv-side

On the recv side (i.e. host/implementer), use the bindFooHost function generated per Foo interface.

// note here we are importing Foo from the input file
import type { Foo } from "my/out/dir/inputs.ts";
import { bindFoo } from "my/out/dir/sides/worker.ts";

// Anything that looks like `WorkerLike` is accepted
// Inside a web worker, you can use `self` to send message
// to the main thread
const worker = getMyWorker();
// The object that will be receiving the calls from remote
const foo: Foo = createMyFoo();

bindFooHost(foo, { worker });

See types.ts for more options available

Delegate

When binding the worker to a host, any implementation of the interface defined in the input (Foo) will work. However, if you recall, the functions in the interface are required to return WorkexPromise, which is a promise of a Result type. This is to ensure that any errors during the message exchange can be caught on the receiving side.

This means that implementation of the host would depend on the Result type, which is not ideal. To solve this, you can use a Delegate type and hostFromDelegate function to create a host implementation that wraps the return value with Result automatically. Of course, everything is still type-checked with TypeScript magic.

import { hostFromDelegate, type Delegate } from "my/out/dir/workex";
import type { Foo } from "my/out/dir/inputs.ts";
import { bindFoo } from "my/out/dir/sides/worker.ts";

const fooDelegate = {
    // note that this function returns a regular Promise, not WorkexPromise
    async doSomething(): Promise<void> {
        ...
    }
} satisfies Delegate<Foo>;

bindFooHost(hostFromDelegate(fooDelegate), { worker });

You might be wondering how exceptions are handled. The next chapter goes through error handling in detail.

Error Handling

During one message exchange, a lot of things can go wrong, including:

  • Some error happens before the message can be sent
    • For example, the other side is already closed
  • The receiving side throws an exception while processing the message
  • The receiving side doesn't respond

This is why the interface methods are required to return a WorkexPromise<T> type, which is a Promise<WorkexResult<T>>. The result type is powered by pure, which is a type-only implementation of Rust's Result type.

The send side can check the result like:


const client = ... // this is the client object

const result = await client.doSomething();
// result is WorkexResult<T>

if (result.err) {
    // handle error
    if (result.err.type === "Catch") {
        const message = result.err.message;
        // message can be a few things:
        // - "Terminated" if the worker is terminated
        // - "Timeout" if the worker doesn't respond, and timeout is set
        //   in the options
        // - Best-effort string representation of the error thrown on the other side
        console.error(message);
        return;
    }
    if (result.err.type === "Internal") {
        // this is an internal error, which should not happen
        console.error("Internal error", result.err.message);
        return;
    }
}

// result.val is inferred to be T
console.log(result.val);

As you noticed, if the remote side throws an exception, it will be caught and converted to a string to send to the original side. This ensures maximum compatibility with the message transport method (Worker, WebSocket, network call...).

This means if you need to get structured error data from the other side, throwing exception is not the best option.

You can consider using the Result type from pure yourself:

// Void is the Result type where `void` is returned on success
import type { Void } from "@pistonite/pure/result";
import type { WorkexPromise } from "./workex";

import type { MyError } from "somewhere/my_error";

export interface Foo {
    doSomethingCanFail(): WorkexPromise<Void<MyError>>;
}

Now you can chain the error handling like this:


const client = ... // this is the client object

const messageResult = await client.doSomething();
// result is WorkexResult<T>

if (messageResult.err) {
    // handle the error like above
    ...
    return;
}

const result = messageResult.val;

if (result.err) {
    // handle your error
    console.error(result.err);
    return;
}

// success...

Check out the documentation for pure on JSR for more into!

Full Example

The examples directory on GitHub contains 2 full examples:

  • standalone with a very simple client and worker implementation with TypeScript, and bun as the build tool
  • vite with client and worker implementation bundled with Vite

Walkthrough: Standalone Example

See this example on GitHub, where you can find the instructions to run it yourself.

Setup

This example is a web application that talks to a web worker.

The web application:

  • Create and starts the worker
  • Makes sure the worker is ready before doing anything
    • Worker will send a message when ready
  • Calls a function on the worker to do some work

The web worker:

  • Does some initialization
  • Signals the web application that it's ready
  • Handles the function call from the web application

Tip

The example uses bun as a build tool. Run bun install to set up the dependencies

Run Workex

The interface can be defined as

// src/msg/proto.ts
import { WorkexPromise as Promise } from "./workex";

// in this case, the app doesn't have an API the worker
// can call. So we only need one interface (one direction).

/**
 * Functions the app can call on the worker
 *
 * @workex:send app
 * @workex:recv worker
 */
export interface MyAwesomeLib {
    /** Do some work and return the result as string */
    doWork(): Promise<string>;
}

Now run workex to generate the interfaces and workex library

workex --protocol app src/msg/proto.ts

Info

Run inside the examples/standalone directory. If you don't have workex installed yet, use cargo run -- instead.

The Worker Side

See the comments in the code that walks through the implementation

// src/worker.ts
// import utilities from workex
import { hostFromDelegate, type Delegate } from "./msg/workex";
// import generated types from `sides`
import { bindMyAwesomeLibHost } from "./msg/sides/worker.ts";
// import input types from the input file `proto.ts`
import type { MyAwesomeLib } from "./msg/proto.ts";

// helper function to help us distinguish between app and worker logs
function print(msg: any) {
    console.log("worker: " + msg);
}

// Do whatever initializations needed in the worker
// here we just log that the worker has started
print("started");

// function to simulate some work that takes some time
// this is synchronous, so we can demonstrate workers are on separate threads
function someExpensiveWork(): string {
    // do some expensive work
    let now = Date.now();
    while (Date.now() - now < 2000) {
        // do nothing
    }
    return "Hello from worker!";
}

// Create the handler to handle the messages sent by app
//
// Using the `Delegate` type, each function here returns a regular
// Promise instead of WorkexPromise. Then later we use `hostFromDelegate`
// to wrap the result of each function as WorkexPromise

// Note that making a class and `new`-ing it will not work
// because how hostFromDelegate is implemented
const handler = {
    async doWork(): Promise<string> {
        print("received doWork request from app");
        const result = someExpensiveWork();
        print("work done!");
        return result;
    },
} satisfies Delegate<MyAwesomeLib>;

const options = { worker: self };

// Now we bind the handler to the worker
// at this point, messages from the app can be handled
const handshake = bindMyAwesomeLibHost(hostFromDelegate(handler), options);

// we use the returned handshake object to notify the other side
// that we are ready.
handshake.initiate();

// initiate() returns a Promise that you can await on.
// If the calls can go both directions, you need to await for that
// before you can start calling the other side (which is what the app does
// in this example)

The Web App Side

See the comments in the code that walks through the implementation

// src/app.ts
// import utilities from workex
import type { WorkexResult } from "./msg/workex";
// import generated types from `sides`
import { MyAwesomeLibClient } from "./msg/sides/app.ts";

// helper function to help us distinguish between app and worker logs
function print(msg: any) {
    console.log("app: " + msg);
}

export async function createWorker(): Promise<MyAwesomeLibClient> {
    print("creating worker");
    // /dist/worker.js is where the build tool puts the worker file
    const worker = new Worker("/dist/worker.js");
    const options = { worker };
    const client = new MyAwesomeLibClient(options);
    // the worker will initiate the handshake, we need to wait for
    // it to be established
    print("waiting for handshake to be established");
    await client.handshake().established();
    print("worker ready");

    // at this point, we have completed the handshake, and both sides are ready
    // to communicate
    return client;
}

async function main() {
    print("starting");
    const worker = await createWorker();

    setTimeout(() => {
        // to prove workers are on separate threads
        // log a message while the worker is synchronously
        // doing some work
        print(
            "if this message is before `work done!`, then worker is on a separate thread",
        );
    }, 1000);

    // the type is inferred. Just putting it here to be clear for you
    const result: WorkexResult<string> = await worker.doWork();
    if (result.val) {
        print("worker returned:" + result.val);
    } else {
        console.error(result.err);
    }

    // cleanup
    print("terminating worker");
    worker.terminate();
}

main();

Run the Example

First let's do a type check with tsc

bunx tsc

Then, build the project

mkdir -p dist
bun build src/app.ts --outfile dist/app.js --minify
bun build src/worker.ts --outfile dist/worker.js --minify

Finally, serve the project

bunx serve .

Open the served page in the browser and open the console. You should see the message exchange working as expected!

app: starting
app: creating worker
app: waiting for handshake to be established
worker: started
app: worker ready
worker: received doWork request from app
app: if this message is before `work done!`, then worker is on a separate thread
worker: work done!
app: worker returned:Hello from worker!
app: terminating worker

Walkthrough: Vite

See this example on GitHub, where you can find the instructions to run it yourself.

Tip

Check out the standalone example first to understand the basics, if you are not already familiar with Vite and React. Otherwise you might get lost pretty fast!

Setup

The standalone example shows a scenario where the app calls the worker through workex. In this example, let's go a step further. The app will call the worker, and the worker needs to call back to the app to get some data.

Tip

This pattern is very useful if there are some very large data that doesn't make sense to send to the worker. Instead, it is required that the worker should compute what it needs, then asks for it.

Workex Setup

Similar to the standalone example, let's define the messaging interfaces first

// src/msg/proto.ts
import { WorkexPromise as Promise } from "./workex";

/**
 * Functions the app can call on the worker
 *
 * @workex:send app
 * @workex:recv worker
 */
export interface GreetWorker {
    /** Ask the app for a name and greet the person! */
    greet(): Promise<string>;
}

/**
 * Functions the worker can call back to the app to get something
 *
 * @workex:send worker
 * @workex:recv app
 */
export interface GreetApp {
    /** Get the name of the person to greet */
    getName(): Promise<string>;
}

Now run workex to generate the interfaces and workex library

workex --protocol greet src/msg/proto.ts

Info

Run inside the examples/vite directory. If you don't have workex installed yet, run npm run workex instead.

The Worker Side

See the comments in the code that walks through the implementation

// src/worker.ts
import { WorkexPromise } from "./msg/workex";
import { GreetAppClient, bindGreetWorkerHost } from "./msg/sides/worker.ts";
import type { GreetWorker } from "./msg/proto.ts";

const options = { worker: self };

// Create the client used to call back to the app
const client = new GreetAppClient(options);

// Create the handler to handle the messages sent by app
// Note that the standalone case we used Delegate,
// here we are showing how to implement the host directly
class Handler implements GreetWorker {
    async greet(): WorkexPromise<string> {
        // get the person's name from the app
        const name = await client.getName();
        // handle potential communication errors
        if (name.err) {
            return name;
        }
        // greet the person
        const greeting = `Hello, ${name.val}!`;
        // return it back to the app
        return { val: greeting };
    }
}

// similar to the standalone example, we will let the worker
// initiate the handshake
const handshake = bindGreetWorkerHost(new Handler(), options);
handshake.initiate();

The App Side

In the React app, we will make a button that will call the worker when clicked.

// src/App.tsx
import { useState } from 'react'
import './App.css'

// Use the vite ?worker syntax to import the module as a worker directly!
import GreetWorker from './worker.ts?worker'
import { GreetWorkerClient, bindGreetAppHost } from './msg/sides/app.ts'
import { GreetApp } from './msg/proto';
import { hostFromDelegate, type Delegate } from './msg/workex';

async function createWorker(): Promise<GreetWorkerClient> {
    // just some example data
    const names = ["Alice", "Bob", "Charlie", "David", "Eve"];
    const randomName = () => names[Math.floor(Math.random() * names.length)];

    // note this setup is very similar to what we are doing in the worker
    const worker = new GreetWorker();
    const client = new GreetWorkerClient({ worker });

    // here we use a delegate to bind the handler
    const handler = {
        async getName(): Promise<string> {
            return randomName();
        }
    } satisfies Delegate<GreetApp>;
    const handshake = bindGreetAppHost(hostFromDelegate(handler), { worker });

    // note the worker side calls initiate() and the app side
    // calls established()
    await handshake.established();

    return client;
}

// make sure you are handling the worker lifecycle correctly
// so you don't have resource leaks, especially if you need 
// to terminate() the worker
//
// here we are just using a simple global variable
const greetClient = createWorker();

function App() {
    const [message, setMessage] = useState("Press the button to greet someone!");

    return (
        <>
            <h1>Workex Example with Vite</h1>
            <div className="card">
                <button onClick={async () => {
                    const client = await greetClient;
                    const message = await client.greet();
                    if (message.val) {
                        setMessage(message.val);
                        return
                    }
                    console.error(message.err);
                }}>
                    Greet
                </button>
                <p>
                    {message}
                </p>
            </div>
        </>
    )
}

export default App

Run the Example

npm run dev

Ignore Linter

If your linters (i.e. ESLint or Prettier) are complaining about the generated code, you should add them to the disabled list:

ESLint

See https://eslint.org/docs/latest/use/configure/ignore

Prettier

Using .prettierignore file:

# workex
path/to/workex
path/to/interfaces
path/to/sides

See more: https://prettier.io/docs/en/ignore.html