Guide

GuideShippingTyped Worklets and Workers

Typed Worklets and Workers

Author AudioWorklet processors and Web Workers as real TypeScript modules and inline them as source strings at build time with @codexo/exojs-build.

Advanced~3 min read

Before you start

Typed Worklets and Workers

An AudioWorklet processor and a Web Worker both run outside the module that created them. The platform takes them as source text - audioWorklet.addModule(url) and new Worker(url) - which is why this code is so often written as a template literal full of JavaScript. That string is invisible to TypeScript, ESLint and your formatter, and it cannot import anything, so every helper it needs gets copied in beside it and the two copies drift.

@codexo/exojs-build removes the trade-off. You author a normal .worklet.ts or .worker.ts module, import it through a query, and the build hands you its bundled source as a string.

npm install --save-dev @codexo/exojs-build

Build setup

Add the plugins to your bundler config. Vite:

import { exojs } from '@codexo/exojs-build';

export default {
    plugins: [exojs()],
};

Rollup takes the same plugins:

import { exojs } from '@codexo/exojs-build';

export default {
    input: 'src/main.js',
    output: { dir: 'dist', format: 'es' },
    plugins: [exojs()],
};

The .ts modules behind the import queries are bundled by esbuild, so Rollup needs no TypeScript plugin for them.

Then tell TypeScript about the two import queries, once, in your tsconfig.json:

{
    "compilerOptions": {
        "types": ["@codexo/exojs-build/client"]
    }
}

exojs() takes one option, minify, off by default so the inlined source stays readable in the dev server and in stack traces. Turn it on for production builds - in Vite, defineConfig(({ mode }) => ({ plugins: [exojs({ minify: mode === 'production' })] })). There is no automatic detection: Rollup has no mode of its own, and guessing one would make the same config behave differently in the two bundlers.

Typed AudioWorklet

Write the DSP as an ordinary module. Nothing about it is worklet-specific, which means your tests can import it directly:

// dsp.ts
export const saturate = (sample: number, drive: number): number => Math.tanh(sample * drive) / Math.tanh(drive);

The processor imports it like any other module:

// saturator.worklet.ts
import { saturate } from './dsp';

class SaturatorProcessor extends AudioWorkletProcessor {
    process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
        const input = inputs[0]?.[0];
        const output = outputs[0]?.[0];

        if (output) {
            for (let i = 0; i < output.length; i++) output[i] = saturate(input?.[i] ?? 0, 4);
        }

        return true;
    }
}

registerProcessor('saturator', SaturatorProcessor);

On the main thread, the ?worklet query gives you that whole module - the imported dsp helper included - as one string:

import processorSource from './saturator.worklet.ts?worklet';

export const addSaturator = async (context: BaseAudioContext): Promise<AudioWorkletNode> => {
    const url = URL.createObjectURL(new Blob([processorSource], { type: 'text/javascript' }));

    try {
        await context.audioWorklet.addModule(url);
    } finally {
        URL.revokeObjectURL(url);
    }

    return new AudioWorkletNode(context, 'saturator');
};

No separate worklet file is emitted and nothing is fetched at run time.

Typed inline worker

The worker side works the same way. A shared module, imported by both halves:

// shared.ts
export const GENERATOR_TAG = 'generator';

export const fibonacci = (n: number): number => {
    let previous = 0;
    let current = 1;

    for (let step = 0; step < n; step++) [previous, current] = [current, previous + current];

    return previous;
};
// generator.worker.ts
import { fibonacci, GENERATOR_TAG } from './shared';

self.onmessage = (event: MessageEvent<number>): void => {
    self.postMessage({ tag: GENERATOR_TAG, value: fibonacci(event.data) });
};
import { InlineWorker } from '@codexo/exojs';

import workerSource from './generator.worker.ts?worker';

const generator = new InlineWorker(workerSource, { name: 'generator' });

generator.worker.onmessage = (event: MessageEvent<{ tag: string; value: number }>): void => {
    console.log(event.data.value);
};

generator.postMessage(30);

InlineWorker owns the object URL for you: it builds the Blob, constructs the worker, revokes the URL again, and terminates on destroy(). It adds no message protocol - generator.worker is the real Worker, so onmessage, transfer lists and structured clone behave exactly as they always do.

The emitted source is classic-script compatible, so a plain new Worker(url) works too and { type: 'module' } is never required.

Type-checking the two odd scopes

Worklet and worker code belongs to a global scope your app’s program cannot select: an AudioWorkletGlobalScope has registerProcessor but no document, and a worker needs lib: webworker, which cannot be combined with lib: dom. Give each its own small tsconfig rather than widening the app’s:

{
    "compilerOptions": { "lib": ["es2022", "webworker"], "types": [], "noEmit": true },
    "include": ["src/**/*.worker.ts"]
}

Exclude those files from your app’s tsconfig.json. The import query still type-checks there, because it resolves through the ambient declarations rather than through the file.

What the query does and does not change

The query, not the filename, selects the transform. ./saturator.worklet.ts imported without ?worklet is still an ordinary module, so a unit test can import the processor’s helpers directly.

What you get from the query is a string, and nothing more. The plugin itself has no opinion about lifetime, termination or messaging; ExoJS supplies the two runtime helpers that string usually needs - InlineWorker for the worker half and registerAudioWorkletProcessor for the worklet half - and anything beyond them stays your code.