Skip to content

Repository files navigation

@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, on and 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 - intercept lets you gate, log, or reject incoming RPCs before they hit your handlers.
  • Chrome DevTools profiling - isProfiling records 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/courier

Quick 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) // 5

Worker

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) // 12

IPC 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:

  1. Set isProfiling: true in development (keep it off in production).
  2. Open Chrome DevTools → Performance.
  3. Record a session while your app runs worker RPCs.
  4. Look for the Courier track group - each bar is one typed emit, hover for the computation/communication breakdown.

Requirements

  • Browser - relies on Worker, MessageChannel and MessagePort
  • ESM - this package ships as ES modules only

Development

pnpm install
pnpm test
pnpm typecheck
pnpm lint
pnpm build

License

MIT © Capacities Labs GmbH

About

Type-safe RPC for Web Workers over MessagePort.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages