@capacities/courier
v1.0.1
Published
Type-safe RPC for Web Workers over MessagePort
Downloads
306
Readme
@capacities/courier
Type-safe RPC for Web Workers over MessagePort.
Courier gives you a type-safe RPC layer between a host and one or more workers. You define the message contract once in TypeScript; emit, on and bind stay fully typed on both sides.
Built at Capacities and used in our own codebase.
Why Courier?
- End-to-end TypeScript - define your contract once;
emit,onand handler arguments/returns are checked on host and every worker. - Direct worker-to-worker calls - the host builds a full mesh of
MessageChannels, so any participant can call any other without relaying through the main thread. - Production-ready protocol - initialization handshake, optional heartbeat, per-event timeouts and disconnect handling are built in.
- Request middleware -
interceptlets you gate, log, or reject incoming RPCs before they hit your handlers. - Chrome DevTools profiling -
isProfilingrecords round-trip measurements with handler vs messaging breakdown. See Profiling.
With three participants, a worker can call another worker directly:
type Definition = {
main: {
onSaved: (id: string) => void
}
processing: {
ingest: (raw: string) => { id: string; title: string; tokens: string[] }
}
database: {
save: (record: { id: string; title: string; tokens: string[] }) => void
get: (id: string) => { id: string; title: string; tokens: string[] } | null
}
}
// Inside the processing worker - no need to bounce through main
const ipc = await courier.worker<Definition, 'processing'>('processing', self)
ipc.on('ingest', async (raw) => {
const record = {
id: crypto.randomUUID(),
title: raw.split('\n')[0] ?? '',
tokens: raw.toLowerCase().split(/\s+/),
}
await ipc.emit('database', 'save', record)
await ipc.emit('main', 'onSaved', record.id)
return record
})Comparison
| | Courier | Comlink | threads.js | Raw postMessage |
| ----------------------------- | --------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------- | --------------------- |
| Model | Named participants, shared contract | Proxy over an exposed object | Thread pool for parallel tasks | Ad-hoc messages |
| API | emit(target, event, …) / on(event, handler) | Comlink.wrap(endpoint).method() | pool.queue(fn) | Custom protocol |
| Typing | ✅ One Definition types every participant's emit/on | ✅ Remote<T> from wrap<T>()¹ | ✅ via spawn<T>()¹ | ❌ |
| Worker-to-worker | ✅ Mesh wired by courier.host | ⚠️ Works over MessagePort; channels are DIY | ❌ Not the focus | ⚠️ DIY |
| Multi-worker setup | ✅ courier.host connects all ports | ⚠️ One wrap() per endpoint | ✅ Worker pool | ⚠️ DIY |
| Handshake & heartbeat | ✅ Built-in | ❌ | ❌ | ❌ |
| RPC timeouts & disconnect | ✅ Built-in | ❌ | ❌ | ❌ |
| Per-RPC DevTools tracks | ✅ Optional isProfiling (handler vs messaging split)² | ❌ No built-in instrumentation | ❌ No built-in instrumentation | ❌ |
| Transferables / callbacks | 🔜 Planned | ✅ Comlink.transfer, Comlink.proxy | ✅ Via Comlink | ⚠️ Manual |
¹ Comlink and threads.js both provide real generic typing (Remote<T>, typed spawn). The gap vs Courier is mostly shape: Courier's single Definition schema types named participants and cross-worker routes together; Comlink/threads.js type the exposed API surface, with occasional as unknown as at the edges.
² Chrome can profile any worker natively (chrome://inspect). This row is about library-level performance.measure() tracks per RPC, not whether profiling is possible at all.
✅ Built-in · ⚠️ Possible, not provided · ❌ Not provided · 🔜 Planned
Install
npm install @capacities/courierQuick start
Define a definition - a map of participant names to their exposed handlers:
type Definition = {
main: {
onResult: (value: number) => void
}
worker: {
compute: (a: number, b: number) => number
}
}Host
import { courier } from '@capacities/courier'
const ipc = courier.host<Definition, 'main'>('main', {
worker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }),
})
ipc.on('onResult', (value) => {
console.log('result:', value)
})
const sum = await ipc.emit('worker', 'compute', 2, 3)
console.log(sum) // 5Worker
import { courier } from '@capacities/courier'
const ipc = await courier.worker<Definition, 'worker'>('worker', self)
ipc.on('compute', (a, b) => a + b)
await ipc.emit('main', 'onResult', 42)Courier handles the handshake: the host spins up MessageChannels between all participants, transfers ports, waits for initialization, then signals readiness.
API
courier.host(self, workers, options?)
Creates the host-side IPC interface. workers maps each worker name to a factory that returns a Worker (or mock).
Returns an Courier.IPC instance synchronously. Workers are terminated when close() is called.
courier.worker(self, scope, options?)
Bootstraps a worker. Pass self (the global WorkerGlobalScope in a real worker).
Returns a Promise<Courier.IPC> that resolves once the host signals readiness.
courier.ipc({ self, ports, ready }, options)
Low-level setup when you manage MessagePorts yourself.
courier.mock(self, callback, options?)
Creates an in-memory host/worker pair for tests - no real Worker thread needed.
import { courier } from '@capacities/courier'
const mock = courier.mock<Definition, 'worker'>('worker', (ipc) => {
ipc.on('compute', (a, b) => a + b)
})
const host = courier.host<Definition, 'main'>('main', {
worker: () => mock,
})
await host.emit('worker', 'compute', 3, 9) // 12IPC interface
Each side exposes:
| Method | Description |
| ------------------------------ | --------------------------------------------------------------------------- |
| emit(target, event, ...args) | Call a handler on another participant. Returns a Promise with the result. |
| on(event, callback) | Register a handler for incoming requests. Returns an unsubscribe function. |
| once(event, callback) | Same as on, but runs at most once. |
| bind(handlers) | Register multiple handlers at once. |
| close() | Tear down listeners and ports. |
| self | This participant's name. |
Handlers may be sync or async - return values and thrown errors are propagated back to the caller.
Options
Pass partial options to override defaults from Courier.DEFAULTS (host, worker and low-level ipc each accept a slightly different subset):
courier.host(
'main',
{ worker: () => new Worker('./worker.js') },
{
timeouts: {
// (Host only) Max time to wait for every worker to finish the initialize handshake.
initialization: 5_000,
// Max time an `intercept` callback may run before the request is rejected.
intercept: 5_000,
// Max time an `emit` may take before its promise rejects.
// Use a number for one global limit, or an object for per-target/per-event overrides.
events: Infinity,
},
// Optional liveness checks. Sends periodic pings; marks a target dead after too many misses.
heartbeat: {
interval: 1_000, // How often to ping each target (ms).
timeout: 500, // How long to wait for a pong before counting a miss (ms).
threshold: 3, // Consecutive misses before `onDead` runs and the target is closed.
isPaused: () => document.hidden, // When true, pauses probes and resets miss counters.
targets: ['worker'], // Which peers to monitor. Defaults to all connected targets.
onDead: (target) => console.warn(`${target} is unresponsive`),
},
// Middleware invoked on incoming requests before they reach your handler.
// Call `proceed()` to continue or `reject(reason)` to fail the request.
// Set to `null` (default) to disable.
intercept: (event, parameters, { proceed, reject }) => {
proceed()
},
// Called when a participant disconnects or is closed (including after heartbeat failure).
onClose: (target) => console.warn(`${target} disconnected`),
// Log internal warnings (handshake failures, port errors, heartbeat issues, etc.).
isVerbose: false,
// (Host only) Record `performance.measure()` entries for Chrome DevTools. See Profiling below.
isProfiling: false,
}
)Worker options are the same except there is no timeouts.initialization and isProfiling / isVerbose are inherited from the host during handshake rather than set on the worker directly.
bind(handlers, { isOnce: true }) accepts a listen option to auto-unsubscribe after the first matching request.
Profiling
Courier has built-in support for the Performance API and Chrome DevTools custom tracks. Enable it on the host - the setting is forwarded to every worker during handshake:
const ipc = courier.host<Definition, 'main'>(
'main',
{ worker: () => new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) },
{ isProfiling: true }
)Every emit then records a performance.measure() entry that shows up in the Chrome Performance panel.
Typical workflow:
- Set
isProfiling: truein development (keep it off in production). - Open Chrome DevTools → Performance.
- Record a session while your app runs worker RPCs.
- Look for the Courier track group - each bar is one typed
emit, hover for the computation/communication breakdown.
Requirements
- Browser - relies on
Worker,MessageChannelandMessagePort - ESM - this package ships as ES modules only
Development
pnpm install
pnpm test
pnpm typecheck
pnpm lint
pnpm buildLicense
MIT © Capacities Labs GmbH
