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
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
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
orset
) - Return type needs to be
WorkexPromise
, which is aPromise<WorkexResult<T>>
- Typically, you can
import { WorkexPromise as Promise }
and use it as if it's a regularPromise
- This type means you need to handle potential errors during the message exchange, before accessing
T
(which itself can be aResult
)
- Typically, you can
Annotations
The tool also looks for annotations in the comments for the interfaces. It supports the following annotations:
@workex:send SIDE
- MarksSIDE
as the send side of the interface@workex:recv SIDE
- MarksSIDE
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 codeinterfaces
directory containing:- One
Foo.send.ts
andFoo.recv.ts
for eachexport interface Foo
send
is consumed by the side that calls theFoo
interface, by using theFooClient
classrecv
is consumed by the side that implements theFoo
interface, by callingbindFooHost
function
- One
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, andbun
as the build toolvite
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
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
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.
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.
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
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